Skip to main content

tsift_agent_doc/
session_markdown.rs

1use anyhow::{Context, Result};
2use serde::Serialize;
3use std::fs;
4use std::io::Read as _;
5use std::path::Path;
6
7const FILE_PROBE_BYTES: usize = 16 * 1024;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
10pub struct AgentDocSessionDocument {
11    pub session_id: Option<String>,
12    pub backlog_items: Vec<AgentDocBacklogItem>,
13    pub queue_items: Vec<AgentDocQueueItem>,
14}
15
16impl AgentDocSessionDocument {
17    pub fn parse(content: &str) -> Self {
18        let mut backlog_items = Vec::new();
19        let mut queue_items = Vec::new();
20        let mut in_queue = false;
21        for (idx, line) in content.lines().enumerate() {
22            let line_number = idx + 1;
23            if let Some(backlog_item) = parse_backlog_line(line, line_number) {
24                backlog_items.push(backlog_item);
25            }
26
27            let trimmed = line.trim();
28            if trimmed.starts_with("<!-- agent:queue") {
29                in_queue = true;
30                continue;
31            }
32            if trimmed.starts_with("<!-- /agent:queue") {
33                in_queue = false;
34                continue;
35            }
36            if in_queue && let Some(queue_item) = parse_queue_line(line, line_number) {
37                queue_items.push(queue_item);
38            }
39        }
40
41        Self {
42            session_id: session_id_from_content(content),
43            backlog_items,
44            queue_items,
45        }
46    }
47
48    pub fn parse_if_session(content: &str) -> Option<Self> {
49        markdown_content_looks_like_agent_doc_session(content).then(|| Self::parse(content))
50    }
51
52    pub fn read(path: &Path) -> Result<Option<Self>> {
53        let content = fs::read_to_string(path)
54            .with_context(|| format!("reading agent-doc session document {}", path.display()))?;
55        Ok(Self::parse_if_session(&content))
56    }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60pub struct AgentDocBacklogItem {
61    pub id: String,
62    pub text: String,
63    pub line: usize,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
67#[serde(tag = "kind", rename_all = "snake_case")]
68pub enum AgentDocQueueItem {
69    Dispatch { value: String, line: usize },
70    Preset { value: String, line: usize },
71    Do { id: String, line: usize },
72}
73
74impl AgentDocQueueItem {
75    pub fn line(&self) -> usize {
76        match self {
77            Self::Dispatch { line, .. } | Self::Preset { line, .. } | Self::Do { line, .. } => {
78                *line
79            }
80        }
81    }
82}
83
84pub fn session_id_from_path(path: &Path) -> Result<Option<String>> {
85    let content = fs::read_to_string(path)
86        .with_context(|| format!("reading agent-doc session document {}", path.display()))?;
87    Ok(session_id_from_content(&content))
88}
89
90pub fn session_id_from_content(content: &str) -> Option<String> {
91    content.lines().find_map(|line| {
92        let trimmed = line.trim();
93        trimmed
94            .strip_prefix("agent_doc_session:")
95            .map(str::trim)
96            .map(|value| value.trim_matches('"').trim_matches('\'').trim())
97            .filter(|value| !value.is_empty())
98            .map(str::to_string)
99    })
100}
101
102pub fn parse_backlog_line(line: &str, line_number: usize) -> Option<AgentDocBacklogItem> {
103    let trimmed = line.trim();
104    if !trimmed.starts_with("- [") {
105        return None;
106    }
107    let start = trimmed.find("[#")?;
108    let after_start = start + 2;
109    let rest = &trimmed[after_start..];
110    let end = rest.find(']')?;
111    let id = rest[..end].trim();
112    if id.is_empty() {
113        return None;
114    }
115    Some(AgentDocBacklogItem {
116        id: id.to_string(),
117        text: rest[end + 1..].trim().to_string(),
118        line: line_number,
119    })
120}
121
122pub fn parse_queue_line(line: &str, line_number: usize) -> Option<AgentDocQueueItem> {
123    let trimmed = line.trim();
124    if let Some(value) = trimmed
125        .strip_prefix("dispatch ")
126        .map(str::trim)
127        .filter(|value| !value.is_empty())
128    {
129        return Some(AgentDocQueueItem::Dispatch {
130            value: value.to_string(),
131            line: line_number,
132        });
133    }
134    if let Some(value) = trimmed
135        .strip_prefix("preset ")
136        .map(str::trim)
137        .filter(|value| !value.is_empty())
138    {
139        return Some(AgentDocQueueItem::Preset {
140            value: value.to_string(),
141            line: line_number,
142        });
143    }
144    let rest = trimmed.strip_prefix("- do [#")?;
145    let end = rest.find(']')?;
146    let id = rest[..end].trim();
147    (!id.is_empty()).then(|| AgentDocQueueItem::Do {
148        id: id.to_string(),
149        line: line_number,
150    })
151}
152
153pub fn markdown_content_looks_like_agent_doc_session(content: &str) -> bool {
154    session_id_from_content(content).is_some()
155        || content.contains("<!-- agent:exchange")
156        || content.contains("<!-- agent:backlog")
157        || content.contains("<!-- agent:queue")
158        || content.lines().any(|line| {
159            let trimmed = line.trim();
160            trimmed == "## Exchange" || trimmed == "## Backlog"
161        })
162}
163
164pub fn markdown_file_looks_like_agent_doc_session(path: &Path) -> bool {
165    read_file_prefix(path, FILE_PROBE_BYTES)
166        .as_deref()
167        .is_some_and(markdown_content_looks_like_agent_doc_session)
168}
169
170pub fn log_content_looks_like_agent_doc_runtime_log(content: &str) -> bool {
171    let mut saw_line = false;
172    for line in content
173        .lines()
174        .map(str::trim)
175        .filter(|line| !line.is_empty())
176        .take(8)
177    {
178        saw_line = true;
179        if !(line.starts_with('[') && line.contains("] ")) {
180            return false;
181        }
182    }
183    saw_line
184}
185
186pub fn log_file_looks_like_agent_doc_runtime_log(path: &Path) -> bool {
187    read_file_prefix(path, FILE_PROBE_BYTES)
188        .as_deref()
189        .is_some_and(log_content_looks_like_agent_doc_runtime_log)
190}
191
192fn read_file_prefix(path: &Path, max_bytes: usize) -> Option<String> {
193    let mut file = fs::File::open(path).ok()?;
194    let mut buffer = Vec::new();
195    file.by_ref()
196        .take(max_bytes as u64)
197        .read_to_end(&mut buffer)
198        .ok()?;
199    Some(String::from_utf8_lossy(&buffer).into_owned())
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn parses_agent_doc_session_backlog_and_queue() {
208        let doc = AgentDocSessionDocument::parse(
209            r##"---
210agent_doc_session: "tsift-v0.1"
211---
212
213## Exchange
214
215<!-- agent:queue preset="#spec" go -->
216dispatch #spec-test-build-install-commit-push
217- do [#x5fw]
218<!-- /agent:queue -->
219
220<!-- agent:backlog -->
221- [ ] [#x5fw] Move CLI parsing into tsift-agent-doc.
222<!-- /agent:backlog -->
223"##,
224        );
225
226        assert_eq!(doc.session_id.as_deref(), Some("tsift-v0.1"));
227        assert_eq!(doc.backlog_items.len(), 1);
228        assert_eq!(doc.backlog_items[0].id, "x5fw");
229        assert_eq!(
230            doc.backlog_items[0].text,
231            "Move CLI parsing into tsift-agent-doc."
232        );
233        assert_eq!(
234            doc.queue_items,
235            vec![
236                AgentDocQueueItem::Dispatch {
237                    value: "#spec-test-build-install-commit-push".to_string(),
238                    line: 8,
239                },
240                AgentDocQueueItem::Do {
241                    id: "x5fw".to_string(),
242                    line: 9,
243                },
244            ]
245        );
246    }
247
248    #[test]
249    fn detects_agent_doc_markdown_and_runtime_logs() {
250        assert!(markdown_content_looks_like_agent_doc_session(
251            "---\nagent_doc_session: tsift-v0.1\n---\n"
252        ));
253        assert!(markdown_content_looks_like_agent_doc_session(
254            "## Exchange\n\n<!-- agent:exchange patch=append -->\n"
255        ));
256        assert!(!markdown_content_looks_like_agent_doc_session(
257            "# Product Backlog\n\n- [ ] normal project note\n"
258        ));
259        assert!(log_content_looks_like_agent_doc_runtime_log(
260            "[1776528398] claude_start mode=fresh_restart\n[1776528399] commit ok\n"
261        ));
262        assert!(!log_content_looks_like_agent_doc_runtime_log(
263            "plain text log line\n[1776528399] commit ok\n"
264        ));
265    }
266}