Skip to main content

sloop/sources/
markdown.rs

1use 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}