Skip to main content

todoapp_app/
io.rs

1//! Export/import a branch (FR-16/FR-17). JSON round-trips exactly; Markdown is
2//! the human/agent task-list form (title + checkbox status + indentation depth).
3
4use serde::{Deserialize, Serialize};
5use todoapp_core::{ComponentStore, Id, Link, LinkKind, Status, TaskEntityStore};
6
7use crate::service::{Error, Services, TaskSnapshot};
8
9/// Self-contained snapshot of a branch: its tasks and the `child`/`blocks` edges
10/// among them. Deterministically ordered so `export → import → export` is stable.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct Export {
13    pub tasks: Vec<TaskSnapshot>,
14    pub links: Vec<Link>,
15}
16
17impl<'a, St: ComponentStore + TaskEntityStore> Services<'a, St> {
18    /// Collect `root` + its descendant subtree (tasks and the edges among them).
19    pub async fn export(&self, root: &Id) -> Result<Export, Error> {
20        let mut ids = self.descendants(root).await;
21        ids.insert(root.clone());
22
23        let mut tasks: Vec<TaskSnapshot> = Vec::new();
24        for id in &ids {
25            if let Ok(t) = self.snapshot(id).await {
26                tasks.push(t);
27            }
28        }
29        tasks.sort_by(|a, b| a.id.cmp(&b.id));
30
31        let mut links = Vec::new();
32        for id in &ids {
33            for kind in [LinkKind::Child, LinkKind::Blocks] {
34                for l in self.links.outgoing(id, kind).await {
35                    if ids.contains(&l.to) {
36                        links.push(l);
37                    }
38                }
39            }
40        }
41        links.sort_by(|a, b| {
42            (a.from.as_str(), a.to.as_str(), format!("{:?}", a.kind)).cmp(&(
43                b.from.as_str(),
44                b.to.as_str(),
45                format!("{:?}", b.kind),
46            ))
47        });
48        Ok(Export { tasks, links })
49    }
50
51    pub async fn export_json(&self, root: &Id) -> Result<String, Error> {
52        let export = self.export(root).await?;
53        serde_json::to_string_pretty(&export).map_err(|e| Error::Import(e.to_string()))
54    }
55
56    /// FR-17: ingest an `Export` (round-trips with [`Self::export`]).
57    pub async fn import_json(&self, json: &str) -> Result<(), Error> {
58        let export: Export =
59            serde_json::from_str(json).map_err(|e| Error::Import(e.to_string()))?;
60        for task in &export.tasks {
61            self.write_snapshot(task).await;
62        }
63        // Tasks with a `child` parent inside the payload (computed over the
64        // import, not the whole store).
65        let parented: std::collections::HashSet<&Id> = export
66            .links
67            .iter()
68            .filter(|l| l.kind == LinkKind::Child)
69            .map(|l| &l.to)
70            .collect();
71        for link in &export.links {
72            self.links.put(link.clone()).await;
73        }
74        // Branch roots (no parent in the payload) attach under the virtual root,
75        // so the re-imported branch is a top-level task (`roots()` invariant).
76        for task in &export.tasks {
77            if !parented.contains(&task.id) {
78                self.attach(&task.id, &Id::root(), None).await?;
79            }
80        }
81        Ok(())
82    }
83
84    /// FR-16: Markdown task list of a branch (DFS over `child`, position order).
85    /// Iterative DFS (explicit stack) to avoid boxing an async recursion.
86    pub async fn export_md(&self, root: &Id) -> Result<String, Error> {
87        let mut out = String::new();
88        let mut stack = vec![(root.clone(), 0usize)];
89        while let Some((id, depth)) = stack.pop() {
90            let task = self.snapshot(&id).await?;
91            let mark = if task.status == Status::Done {
92                "x"
93            } else {
94                " "
95            };
96            out.push_str(&"  ".repeat(depth));
97            out.push_str(&format!("- [{mark}] {}\n", task.title));
98            // push children in reverse so the first sibling is visited next
99            for link in self.children_of(&id).await.into_iter().rev() {
100                stack.push((link.to, depth + 1));
101            }
102        }
103        Ok(out)
104    }
105
106    /// FR-17: parse a Markdown task list into a tree (indent = depth). Status
107    /// comes from the checkbox (`[x]` → done, else todo). Returns the roots.
108    pub async fn import_md(&self, md: &str) -> Result<Vec<TaskSnapshot>, Error> {
109        let mut roots = Vec::new();
110        let mut stack: Vec<Id> = Vec::new();
111        for raw in md.lines() {
112            let Some((depth, mark, title)) = parse_md_line(raw) else {
113                continue;
114            };
115            stack.truncate(depth);
116            let parent = stack.last().cloned();
117            let status = if mark == 'x' {
118                Status::Done
119            } else {
120                Status::Todo
121            };
122            let task = self.create(title, parent.as_ref(), status, []).await?;
123            if parent.is_none() {
124                roots.push(task.clone());
125            }
126            stack.push(task.id.clone());
127        }
128        Ok(roots)
129    }
130}
131
132/// `(depth, checkbox char, title)` for a `- [ ] ...` line; `None` for others.
133fn parse_md_line(line: &str) -> Option<(usize, char, &str)> {
134    let indent = line.len() - line.trim_start().len();
135    let depth = indent / 2;
136    let rest = line.trim_start();
137    let rest = rest.strip_prefix("- [")?;
138    let mark = rest.chars().next()?;
139    let title = rest.get(1..)?.strip_prefix("] ")?.trim();
140    Some((depth, mark, title))
141}