tsift_agent_doc/
session_markdown.rs1use 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}