sloop/sources/
markdown.rs1use std::fs;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use crate::flow::Flow;
6use crate::frontmatter::{self, Frontmatter};
7use crate::outcome::Outcome;
8use crate::post::parse_ticket_frontmatter;
9
10use super::{AuthoredTicket, FlowSource, SourceError, TicketSource};
11
12pub struct MarkdownTicketSource {
13 root: PathBuf,
14 ticket_dir: PathBuf,
15}
16
17impl MarkdownTicketSource {
18 pub fn new(root: impl Into<PathBuf>, ticket_dir: impl Into<PathBuf>) -> Self {
19 Self {
20 root: root.into(),
21 ticket_dir: ticket_dir.into(),
22 }
23 }
24}
25
26impl TicketSource for MarkdownTicketSource {
27 fn pull(&self) -> Result<Vec<AuthoredTicket>, SourceError> {
28 let directory = self.root.join(&self.ticket_dir);
29 let mut paths = Vec::new();
30 collect_markdown_files(&directory, &mut paths)?;
31 paths.sort();
32
33 paths
34 .into_iter()
35 .map(|path| {
36 let relative = path.strip_prefix(&self.root).map_err(|_| {
37 SourceError::new(format!(
38 "ticket path `{}` is outside repository `{}`",
39 path.display(),
40 self.root.display()
41 ))
42 })?;
43 let source_ref = relative.to_string_lossy().into_owned();
44 let content = fs::read_to_string(&path).map_err(|error| io_error(&path, error))?;
45 let (frontmatter, validation_error) =
46 match parse_ticket_frontmatter(&content, &source_ref) {
47 Ok(frontmatter) => (frontmatter, None),
48 Err(error) => (recover_frontmatter(&content), Some(error.to_string())),
49 };
50 let body = frontmatter::body(&content).unwrap_or_default().to_owned();
51 Ok(AuthoredTicket {
52 frontmatter,
53 body,
54 source: "local".into(),
55 source_ref,
56 file_path: Some(relative.to_path_buf()),
57 original_content: Some(content),
58 validation_error,
59 })
60 })
61 .collect()
62 }
63
64 fn report(&self, _ticket_id: &str, _outcome: &Outcome) -> Result<(), SourceError> {
65 Ok(())
66 }
67}
68
69pub struct MarkdownFlowSource {
70 root: PathBuf,
71}
72
73impl MarkdownFlowSource {
74 pub fn new(root: impl Into<PathBuf>) -> Self {
75 Self { root: root.into() }
76 }
77}
78
79impl FlowSource for MarkdownFlowSource {
80 fn pull(&self) -> Result<Vec<Flow>, SourceError> {
81 let directory = self.root.join(".agents/sloop/flows");
82 let entries = match fs::read_dir(&directory) {
83 Ok(entries) => entries,
84 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
85 Err(error) => return Err(io_error(&directory, error)),
86 };
87 let mut paths = entries
88 .map(|entry| {
89 entry
90 .map(|entry| entry.path())
91 .map_err(|error| io_error(&directory, error))
92 })
93 .collect::<Result<Vec<_>, _>>()?;
94 paths.retain(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "yaml"));
95 paths.sort();
96
97 paths
98 .into_iter()
99 .map(|path| {
100 let name = path
101 .file_stem()
102 .and_then(|stem| stem.to_str())
103 .ok_or_else(|| {
104 SourceError::new(format!(
105 "{}: flow filename must be valid UTF-8",
106 path.display()
107 ))
108 })?;
109 let contents = fs::read_to_string(&path).map_err(|error| io_error(&path, error))?;
110 crate::flow::parse(name, &contents)
111 .map_err(|message| SourceError::new(format!("{}: {message}", path.display())))
112 })
113 .collect()
114 }
115}
116
117fn collect_markdown_files(directory: &Path, paths: &mut Vec<PathBuf>) -> Result<(), SourceError> {
118 let entries = match fs::read_dir(directory) {
119 Ok(entries) => entries,
120 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
121 Err(error) => return Err(io_error(directory, error)),
122 };
123 for entry in entries {
124 let path = entry.map_err(|error| io_error(directory, error))?.path();
125 if path.is_dir() {
126 collect_markdown_files(&path, paths)?;
127 } else if path.extension().and_then(|extension| extension.to_str()) == Some("md") {
128 paths.push(path);
129 }
130 }
131 Ok(())
132}
133
134fn recover_frontmatter(content: &str) -> Frontmatter {
135 if let Ok(frontmatter) = frontmatter::parse(content) {
136 return frontmatter;
137 }
138 let Some(yaml) = raw_yaml(content) else {
139 return Frontmatter::default();
140 };
141 let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(yaml) else {
142 return Frontmatter::default();
143 };
144 let Some(mapping) = value.as_mapping() else {
145 return Frontmatter::default();
146 };
147
148 let blocked_by = mapping
149 .get("blocked_by")
150 .and_then(serde_yaml::Value::as_sequence)
151 .map(|values| {
152 values
153 .iter()
154 .filter_map(serde_yaml::Value::as_str)
155 .map(str::to_owned)
156 .collect()
157 })
158 .unwrap_or_default();
159 let mut recovered =
160 Frontmatter::sourced(scalar(mapping, "name").unwrap_or_default(), blocked_by);
161 recovered.id = scalar(mapping, "id");
162 recovered.project = scalar(mapping, "project");
163 recovered.title = scalar(mapping, "title");
164 recovered.worktree = scalar(mapping, "worktree");
165 recovered.target = scalar(mapping, "target");
166 recovered.model = scalar(mapping, "model");
167 recovered.effort = scalar(mapping, "effort");
168 recovered.flow = scalar(mapping, "flow");
169 recovered
170}
171
172fn raw_yaml(content: &str) -> Option<&str> {
173 let after_open = content.strip_prefix("---\n")?;
174 let end = after_open
175 .split_inclusive('\n')
176 .scan(0, |offset, line| {
177 let start = *offset;
178 *offset += line.len();
179 Some((start, line))
180 })
181 .find_map(|(offset, line)| (line == "---\n" || line == "---").then_some(offset))
182 .unwrap_or(after_open.len());
183 Some(&after_open[..end])
184}
185
186fn scalar(mapping: &serde_yaml::Mapping, key: &str) -> Option<String> {
187 match mapping.get(key) {
188 Some(serde_yaml::Value::String(value)) => Some(value.clone()),
189 Some(serde_yaml::Value::Number(value)) => Some(value.to_string()),
190 _ => None,
191 }
192}
193
194fn io_error(path: &Path, error: io::Error) -> SourceError {
195 SourceError::new(format!("{}: {error}", path.display()))
196}
197
198#[cfg(test)]
199mod tests {
200 use std::fs;
201
202 use tempfile::tempdir;
203
204 use super::MarkdownTicketSource;
205 use crate::sources::TicketSource;
206
207 #[test]
208 fn invalid_files_are_returned_with_recovered_identity() {
209 let root = tempdir().unwrap();
210 let directory = root.path().join("tickets");
211 fs::create_dir(&directory).unwrap();
212 let content = "---\nid: T1\nproject: example\nname: Broken\nblocked_by: T0\n---\nbody\n";
213 fs::write(directory.join("invalid.md"), content).unwrap();
214
215 let tickets = MarkdownTicketSource::new(root.path(), "tickets")
216 .pull()
217 .unwrap();
218
219 assert_eq!(tickets.len(), 1);
220 assert_eq!(tickets[0].frontmatter.id.as_deref(), Some("T1"));
221 assert_eq!(tickets[0].frontmatter.project.as_deref(), Some("example"));
222 assert!(tickets[0].validation_error.is_some());
223 assert_eq!(tickets[0].source_ref, "tickets/invalid.md");
224 assert_eq!(tickets[0].original_content.as_deref(), Some(content));
225 }
226
227 #[test]
228 fn unterminated_parseable_yaml_still_recovers_the_id() {
229 let root = tempdir().unwrap();
230 let directory = root.path().join("tickets");
231 fs::create_dir(&directory).unwrap();
232 fs::write(directory.join("invalid.md"), "---\nid: T2\n").unwrap();
233
234 let tickets = MarkdownTicketSource::new(root.path(), "tickets")
235 .pull()
236 .unwrap();
237
238 assert_eq!(tickets[0].frontmatter.id.as_deref(), Some("T2"));
239 assert!(tickets[0].validation_error.is_some());
240 }
241}