use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "name", rename_all = "snake_case")]
pub enum Speaker {
Me,
Others,
Named(String),
}
impl Speaker {
pub fn label(&self) -> &str {
match self {
Speaker::Me => "Я",
Speaker::Others => "Собеседники",
Speaker::Named(name) => name,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Segment {
pub start_ms: u64,
pub speaker: Speaker,
pub text: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Transcript {
pub segments: Vec<Segment>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum ParseError {
#[error("строка {line}: нет метки времени вида [чч:мм:сс] перед первой репликой")]
TextBeforeFirstSegment { line: usize },
#[error("строка {line}: метка времени «{stamp}» не разбирается")]
BadTimestamp { line: usize, stamp: String },
#[error("строка {line}: после метки времени нет «Имя:»")]
NoSpeaker { line: usize },
#[error("строка {line}: время идёт назад")]
TimeGoesBack { line: usize },
#[error("в тексте нет ни одной реплики")]
Empty,
}
impl Transcript {
pub fn parse(text: &str) -> Result<Self, ParseError> {
let mut segments: Vec<Segment> = Vec::new();
for (index, raw) in text.lines().enumerate() {
let line = index + 1;
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
let Some(rest) = trimmed.strip_prefix('[') else {
let Some(last) = segments.last_mut() else {
return Err(ParseError::TextBeforeFirstSegment { line });
};
last.text.push(' ');
last.text.push_str(trimmed);
continue;
};
let Some((stamp, rest)) = rest.split_once(']') else {
return Err(ParseError::BadTimestamp {
line,
stamp: rest.to_string(),
});
};
let start_ms = parse_timestamp(stamp).ok_or_else(|| ParseError::BadTimestamp {
line,
stamp: stamp.to_string(),
})?;
let Some((name, said)) = rest.split_once(':') else {
return Err(ParseError::NoSpeaker { line });
};
let name = name.trim();
if name.is_empty() {
return Err(ParseError::NoSpeaker { line });
}
if segments.last().is_some_and(|prev| prev.start_ms > start_ms) {
return Err(ParseError::TimeGoesBack { line });
}
segments.push(Segment {
start_ms,
speaker: Speaker::Named(name.to_string()),
text: said.trim().to_string(),
});
}
if segments.is_empty() {
return Err(ParseError::Empty);
}
Ok(Transcript { segments })
}
pub fn duration_ms(&self) -> u64 {
self.segments.last().map_or(0, |s| s.start_ms)
}
pub fn to_prompt_text(&self) -> String {
let mut out = String::new();
for (i, s) in self.segments.iter().enumerate() {
out.push_str(&format!(
"#{i} [{}] {}: {}\n",
format_timestamp(s.start_ms),
s.speaker.label(),
s.text
));
}
out
}
pub fn to_markdown(&self, title: &str) -> String {
let mut out = format!("# {title} — расшифровка\n\n");
for s in &self.segments {
out.push_str(&format!(
"`{}` **{}:** {}\n\n",
format_timestamp(s.start_ms),
s.speaker.label(),
s.text
));
}
out
}
}
pub fn parse_timestamp(stamp: &str) -> Option<u64> {
let parts: Vec<&str> = stamp.trim().split(':').collect();
let nums: Vec<u64> = parts
.iter()
.map(|p| {
if p.len() == 2 || p.len() == 1 {
p.parse().ok()
} else {
None
}
})
.collect::<Option<_>>()?;
let (h, m, s) = match nums.as_slice() {
[m, s] => (0, *m, *s),
[h, m, s] => (*h, *m, *s),
_ => return None,
};
if m >= 60 || s >= 60 {
return None;
}
Some(((h * 60 + m) * 60 + s) * 1000)
}
pub fn format_timestamp(ms: u64) -> String {
let total = ms / 1000;
format!("{:02}:{:02}:{:02}", total / 3600, total / 60 % 60, total % 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_segments_and_continuations() {
let t = Transcript::parse(
"[00:00:05] Анна: Начнём.\n\n[00:01:10] Борис: Отчёт готов,\nно без графиков.\n",
)
.unwrap();
assert_eq!(t.segments.len(), 2);
assert_eq!(t.segments[1].start_ms, 70_000);
assert_eq!(t.segments[1].speaker, Speaker::Named("Борис".into()));
assert_eq!(t.segments[1].text, "Отчёт готов, но без графиков.");
assert_eq!(t.duration_ms(), 70_000);
}
#[test]
fn colon_inside_the_text_stays_in_the_text() {
let t = Transcript::parse("[00:10] Анна: Срок: пятница").unwrap();
assert_eq!(t.segments[0].start_ms, 10_000);
assert_eq!(t.segments[0].text, "Срок: пятница");
}
#[test]
fn rejects_bad_input_with_line_numbers() {
assert_eq!(
Transcript::parse("привет\n[00:00:01] А: б"),
Err(ParseError::TextBeforeFirstSegment { line: 1 })
);
assert_eq!(
Transcript::parse("[00:99:00] А: б"),
Err(ParseError::BadTimestamp {
line: 1,
stamp: "00:99:00".into()
})
);
assert_eq!(
Transcript::parse("[00:00:01] без двоеточия"),
Err(ParseError::NoSpeaker { line: 1 })
);
assert_eq!(
Transcript::parse("[00:00:05] А: б\n[00:00:01] В: г"),
Err(ParseError::TimeGoesBack { line: 2 })
);
assert_eq!(Transcript::parse(" \n"), Err(ParseError::Empty));
}
#[test]
fn timestamps_round_trip() {
for ms in [0, 59_000, 3_600_000, 5_025_000] {
assert_eq!(parse_timestamp(&format_timestamp(ms)), Some(ms));
}
assert_eq!(parse_timestamp("1:2:3:4"), None);
assert_eq!(parse_timestamp("abc"), None);
}
#[test]
fn prompt_text_numbers_segments() {
let t = Transcript::parse("[00:00:01] А: раз\n[00:00:02] Б: два").unwrap();
assert_eq!(t.to_prompt_text(), "#0 [00:00:01] А: раз\n#1 [00:00:02] Б: два\n");
}
#[test]
fn fixtures_parse() {
for (name, text, segments) in [
(
"planerka",
include_str!("../../fixtures/transcripts/planerka.txt"),
24,
),
(
"klient",
include_str!("../../fixtures/transcripts/klient.txt"),
15,
),
(
"pustaya",
include_str!("../../fixtures/transcripts/pustaya.txt"),
6,
),
] {
let t = Transcript::parse(text).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(t.segments.len(), segments, "{name}");
}
}
#[test]
fn capture_speakers_have_fixed_labels() {
assert_eq!(Speaker::Me.label(), "Я");
assert_eq!(Speaker::Others.label(), "Собеседники");
let json = serde_json::to_string(&Speaker::Named("Анна".into())).unwrap();
assert_eq!(json, r#"{"kind":"named","name":"Анна"}"#);
}
}