Skip to main content

vissue_core/
projection.rs

1//! Projection: the boards a shared repository shows from other trackers,
2//! and the way work goes back to them.
3//!
4//! A tracker is a directory of org files, so sharing one across machines
5//! has meant copying files by hand and a shell script per repository that
6//! knew every project by name. This module makes that one declaration and
7//! one verb. The repository's `vissue.toml` names its boards:
8//!
9//! ```toml
10//! [[projection.board]]
11//! project = "ljos"                        # the project's name on its source
12//! source  = "vault"                        # a [layouts.*] name from the user's
13//!                                          # config, "self", or a path
14//! mirror  = "Software/ljos/issues-mirror.org"
15//! inbox   = "Software/ljos/inbox.org"      # optional: `* TODO` headings become issues
16//! claims  = "Software/ljos/claims.org"     # optional: `* TODO claim ID as AGENT` lines
17//! ```
18//!
19//! `vissue project` folds each inbox into its source, applies each claims
20//! file, and rewrites each mirror; `--check` says which mirrors are stale.
21//! A source that is not on this machine is reported and its mirror left as
22//! projected, so a delegate can run the same verb and learn what it can do
23//! here. `vissue show ID` on an id that lives only in a mirror answers from
24//! the mirror and names the inbox to write to.
25
26use std::fs;
27use std::path::{Path, PathBuf};
28
29use anyhow::Context;
30use serde::Deserialize;
31
32use crate::config::Layout;
33use crate::error::Result;
34use crate::mirror::{self, Format};
35use crate::ops;
36use crate::router::Router;
37use crate::store;
38
39/// One projected board, as `[[projection.board]]` declares it.
40#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
41#[serde(deny_unknown_fields)]
42pub struct Board {
43    /// The project's name on its source tracker.
44    pub project: String,
45    /// `self`, a `[layouts.*]` name from the user's config, or a path.
46    #[serde(default = "self_source")]
47    pub source: String,
48    /// The mirror file, relative to the repository root.
49    pub mirror: PathBuf,
50    /// An inbox whose unstamped `* TODO` headings fold into the source.
51    #[serde(default)]
52    pub inbox: Option<PathBuf>,
53    /// A file of `* TODO claim ID as AGENT` and `release` lines to apply.
54    #[serde(default)]
55    pub claims: Option<PathBuf>,
56}
57
58fn self_source() -> String {
59    "self".to_string()
60}
61
62#[derive(Debug, Default, Deserialize)]
63#[serde(default)]
64struct Projection {
65    board: Vec<Board>,
66}
67
68#[derive(Debug, Default, Deserialize)]
69#[serde(default)]
70struct File {
71    projection: Projection,
72}
73
74/// The boards `<root>/vissue.toml` declares; none when it declares none.
75///
76/// # Errors
77///
78/// A `vissue.toml` that cannot be read or parsed.
79pub fn boards(root: &Path) -> Result<Vec<Board>> {
80    let path = root.join("vissue.toml");
81    if !path.exists() {
82        return Ok(Vec::new());
83    }
84    let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
85    let parsed: File = toml::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
86    Ok(parsed.projection.board)
87}
88
89/// The tracker a board's `source` names, when it is on this machine: the
90/// repository's own tracker for `self`, a `[layouts.*]` name from the
91/// user's config, else a path that holds a tracker.
92#[must_use]
93pub fn resolve_source(router: &Router, source: &str) -> Option<Layout> {
94    if source == "self" {
95        return Some(router.default_layout().clone());
96    }
97    if let Some(named) = router.named_layout(source) {
98        return Some(named.clone());
99    }
100    // A path source is one on this machine that holds a tracker; a bare
101    // layout name the user's config does not know is not a directory here.
102    let expanded = if let Some(rest) = source.strip_prefix("~/") {
103        std::env::var_os("HOME").map(|h| PathBuf::from(h).join(rest))?
104    } else {
105        PathBuf::from(source)
106    };
107    if !expanded.is_dir() {
108        return None;
109    }
110    let layout = crate::config::layout_at(&expanded).ok()?;
111    if !layout.root().join("vissue.toml").is_file() && !layout.projects_dir().is_dir() {
112        return None;
113    }
114    Some(layout)
115}
116
117/// What one run of `vissue project` did or found.
118#[derive(Debug, Default)]
119pub struct Outcome {
120    /// One line per step, in order.
121    pub lines: Vec<String>,
122    /// Files this run rewrote, for the caller to commit.
123    pub touched: Vec<PathBuf>,
124    /// Mirrors found stale under `--check`.
125    pub stale: usize,
126    /// Boards whose source is not on this machine.
127    pub skipped: usize,
128}
129
130/// Run the projection declared under `repo`: fold, claim, mirror, or with
131/// `check` only compare each mirror's stamp against its source.
132///
133/// # Errors
134///
135/// A board's fold, claim, mirror or check failing; a missing source is not
136/// an error, it is reported and skipped.
137pub fn project(router: &Router, repo: &Path, check: bool) -> Result<Outcome> {
138    let mut out = Outcome::default();
139    let boards = boards(repo)?;
140    if boards.is_empty() {
141        out.lines.push(format!(
142            "no [[projection.board]] in {}; nothing to project",
143            repo.join("vissue.toml").display()
144        ));
145        return Ok(out);
146    }
147    for board in &boards {
148        let mirror_path = repo.join(&board.mirror);
149        let Some(source) = resolve_source(router, &board.source) else {
150            out.skipped += 1;
151            out.lines.push(format!(
152                "{}: source {} is not on this seat; {} stays as projected",
153                board.project,
154                board.source,
155                board.mirror.display()
156            ));
157            continue;
158        };
159        let projects = vec![board.project.clone()];
160        if check {
161            if mirror_path.exists() {
162                let verdict = mirror::check(&source, &mirror_path, &projects)?;
163                if !verdict.fresh {
164                    out.stale += 1;
165                }
166                out.lines
167                    .push(format!("{}: {}", board.project, verdict.report.trim_end()));
168            } else {
169                out.stale += 1;
170                out.lines.push(format!(
171                    "{}: {} does not exist yet",
172                    board.project,
173                    board.mirror.display()
174                ));
175            }
176            continue;
177        }
178        if let Some(inbox) = &board.inbox {
179            let inbox_path = repo.join(inbox);
180            if inbox_path.exists() {
181                let folded = ops::fold(&source, &inbox_path, &board.project)?;
182                out.lines
183                    .push(format!("{}: {}", board.project, folded.trim_end()));
184                out.touched.push(inbox_path);
185            }
186        }
187        if let Some(claims) = &board.claims {
188            let claims_path = repo.join(claims);
189            if claims_path.exists() && apply_claims(&source, &claims_path, &mut out.lines)? {
190                out.touched.push(claims_path);
191            }
192        }
193        let text = mirror::render(&source, &projects, Format::Org, None)?;
194        if let Some(parent) = mirror_path.parent() {
195            fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
196        }
197        store::replace_file_atomically(&mirror_path, &text)?;
198        out.lines.push(format!(
199            "{}: wrote {}",
200            board.project,
201            board.mirror.display()
202        ));
203        out.touched.push(mirror_path);
204    }
205    Ok(out)
206}
207
208/// Apply the unstamped lines of a claims file to the source and stamp them
209/// in place. Returns whether the file changed.
210fn apply_claims(source: &Layout, path: &Path, lines: &mut Vec<String>) -> Result<bool> {
211    let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
212    let mut changed = false;
213    let mut rewritten = Vec::new();
214    for line in text.lines() {
215        let stamped = if let Some(rest) = line.strip_prefix("* TODO claim ") {
216            match rest.rsplit_once(" as ") {
217                Some((issue, agent)) => {
218                    let result = ops::claim_as(source, issue.trim(), false, agent.trim())
219                        .map_or_else(|e| format!("FAILED {e}"), |ok| ok.trim().to_string());
220                    lines.push(format!("claim {issue} as {agent}: {result}"));
221                    Some(format!(
222                        "* DONE claim {} as {} :: {result}",
223                        issue.trim(),
224                        agent.trim()
225                    ))
226                }
227                None => None,
228            }
229        } else if let Some(rest) = line.strip_prefix("* TODO release ") {
230            match rest.rsplit_once(" as ") {
231                Some((issue, agent)) => {
232                    let result = ops::update(source, issue.trim(), Some("TODO"), None, None, None)
233                        .map_or_else(|e| format!("FAILED {e}"), |ok| ok.report);
234                    lines.push(format!("release {issue} as {agent}: {result}"));
235                    Some(format!(
236                        "* DONE release {} as {} :: {result}",
237                        issue.trim(),
238                        agent.trim()
239                    ))
240                }
241                None => None,
242            }
243        } else if let Some(rest) = line.strip_prefix("* TODO done ") {
244            // A seat without the source closes its ticket through the file;
245            // the state moves and the claim releases as an update would.
246            match rest.rsplit_once(" as ") {
247                Some((issue, agent)) => {
248                    let result = ops::update(source, issue.trim(), Some("DONE"), None, None, None)
249                        .map_or_else(|e| format!("FAILED {e}"), |ok| ok.report);
250                    lines.push(format!("done {issue} as {agent}: {result}"));
251                    Some(format!(
252                        "* DONE done {} as {} :: {result}",
253                        issue.trim(),
254                        agent.trim()
255                    ))
256                }
257                None => None,
258            }
259        } else {
260            None
261        };
262        match stamped {
263            Some(s) => {
264                changed = true;
265                rewritten.push(s);
266            }
267            None => rewritten.push(line.to_string()),
268        }
269    }
270    if changed {
271        let mut body = rewritten.join("\n");
272        body.push('\n');
273        store::replace_file_atomically(path, &body)?;
274    }
275    Ok(changed)
276}
277
278/// An id that lives in one of the repository's mirrors: the board and the
279/// heading's text as the mirror carries it, so a reader on a machine
280/// without the source still gets an answer and is told where to write.
281#[must_use]
282pub fn find_in_mirrors(repo: &Path, id: &str) -> Option<(Board, String)> {
283    for board in boards(repo).ok()? {
284        let path = repo.join(&board.mirror);
285        let Ok(text) = fs::read_to_string(&path) else {
286            continue;
287        };
288        let lines: Vec<&str> = text.lines().collect();
289        let Some(at) = lines.iter().position(|l| {
290            let t = l.trim();
291            t.starts_with(":ID:") && t[4..].trim() == id
292        }) else {
293            continue;
294        };
295        let start = lines[..at]
296            .iter()
297            .rposition(|l| l.starts_with("** "))
298            .unwrap_or(at);
299        let end = lines[at..]
300            .iter()
301            .position(|l| l.starts_with("** ") || l.starts_with("* "))
302            .map_or(lines.len(), |n| at + n);
303        let block: Vec<String> = lines[start..end]
304            .iter()
305            .map(|l| l.strip_prefix('*').unwrap_or(l).to_string())
306            .collect();
307        return Some((board, block.join("\n").trim_end().to_string() + "\n"));
308    }
309    None
310}
311
312/// The one line a reader of a projected issue needs beside the heading.
313#[must_use]
314pub fn projected_note(board: &Board) -> String {
315    match &board.inbox {
316        Some(inbox) => format!(
317            "read-only projection of {} from {}; write discovered work to {}",
318            board.project,
319            board.source,
320            inbox.display()
321        ),
322        None => format!(
323            "read-only projection of {} from {}; it takes no work back",
324            board.project, board.source
325        ),
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn a_board_is_read_from_the_repository_config() {
335        let dir = std::env::temp_dir().join(format!("vissue-proj-{}", std::process::id()));
336        fs::create_dir_all(&dir).unwrap();
337        fs::write(
338            dir.join("vissue.toml"),
339            "prefix = \"Issues\"\n\n[[projection.board]]\nproject = \"surf\"\nmirror = \"Software/surf/issues-mirror.org\"\n\n[[projection.board]]\nproject = \"ljos\"\nsource = \"vault\"\nmirror = \"Software/ljos/issues-mirror.org\"\ninbox = \"Software/ljos/inbox.org\"\n",
340        )
341        .unwrap();
342        let boards = boards(&dir).unwrap();
343        assert_eq!(boards.len(), 2);
344        assert_eq!(boards[1].source, "vault");
345        assert_eq!(
346            boards[1].inbox.as_deref(),
347            Some(Path::new("Software/ljos/inbox.org"))
348        );
349        assert_eq!(
350            boards[0].source, "self",
351            "a board with no source is this tracker's"
352        );
353        // A projected id answers from the mirror and names the inbox; the
354        // surf mirror, listed first and without the id, is passed over.
355        fs::create_dir_all(dir.join("Software/surf")).unwrap();
356        fs::write(
357            dir.join("Software/surf/issues-mirror.org"),
358            "#+TITLE: vissue mirror\n\n* surf\n** TODO [#B] Unrelated\n:PROPERTIES:\n:ID:         surf-aaaa\n:END:\n",
359        )
360        .unwrap();
361        fs::create_dir_all(dir.join("Software/ljos")).unwrap();
362        fs::write(
363            dir.join("Software/ljos/issues-mirror.org"),
364            "#+TITLE: vissue mirror\n# SYNC: digest=1 generation=1\n\n* ljos\n** TODO [#A] The seat under a herd :task:\n:PROPERTIES:\n:ID:         ljos-kcd6\n:END:\n\nBody line.\n** DONE [#C] Another\n:PROPERTIES:\n:ID:         ljos-zzzz\n:END:\n",
365        )
366        .unwrap();
367        let (board, text) = find_in_mirrors(&dir, "ljos-kcd6").expect("found in the mirror");
368        assert_eq!(board.project, "ljos");
369        assert!(
370            text.starts_with("* TODO [#A] The seat under a herd"),
371            "{text}"
372        );
373        assert!(text.contains("Body line."), "{text}");
374        assert!(!text.contains("Another"), "{text}");
375        assert!(projected_note(&board).contains("Software/ljos/inbox.org"));
376        assert!(find_in_mirrors(&dir, "ljos-none").is_none());
377        let _ = fs::remove_dir_all(&dir);
378    }
379}