Skip to main content

vissue_core/
mirror.rs

1//! A read-only projection of selected projects into a single shareable file.
2//!
3//! The mirror exists so a collaborator who cannot reach the tracker still sees
4//! the backlog. It carries a banner naming it as generated output, because the
5//! next run overwrites it and hand edits are lost.
6
7use anyhow::{Context, anyhow};
8
9use crate::error::Result;
10use chrono::Local;
11use std::fmt::Write as _;
12use std::path::Path;
13
14use crate::config::Layout;
15use crate::digest::{CorpusDigest, corpus_digest};
16use crate::model::{IssueHeading, TODO_HEADER, today_inactive_bracket};
17use crate::store::{IssueDoc, list_projects};
18
19/// Body lines carried into the projection before it is cut short.
20pub const BODY_LINES: usize = 12;
21
22const BANNER: &str =
23    "MIRROR: generated by `vissue mirror`. Read-only projection; edits here are overwritten.";
24
25/// Output shape for [`render`].
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Format {
28    /// Org file with a banner, planning lines, and drawers.
29    Org,
30    /// Markdown file with metadata as bullets.
31    Markdown,
32}
33
34impl Format {
35    /// Parse `org`, `markdown`, or `md`.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error if `s` is not one of those names.
40    pub fn parse(s: &str) -> Result<Self> {
41        match s {
42            "org" => Ok(Format::Org),
43            "markdown" | "md" => Ok(Format::Markdown),
44            other => Err(anyhow!("unknown format {other:?}; allowed: org, markdown").into()),
45        }
46    }
47}
48
49/// What a mirror was generated against, written into its header so a reader
50/// can tell whether the file still matches the tracker.
51///
52/// Every field but `at` is a function of the corpus, so two runs over an
53/// unchanged tracker differ only in the timestamp.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SyncStamp {
56    /// Combined corpus digest the mirror was generated against.
57    pub digest: String,
58    /// Event-log generation at stamp time.
59    pub generation: u64,
60    /// Issue count at stamp time.
61    pub issues: usize,
62    /// Project name paired with its sub-digest, so a staleness check can name
63    /// which project moved rather than only that something did.
64    pub projects: Vec<(String, String)>,
65    /// Local timestamp the stamp was written, `YYYY-MM-DDTHH:MM`.
66    pub at: String,
67}
68
69impl SyncStamp {
70    /// Build a stamp from a digest and a caller-supplied timestamp.
71    pub fn from_digest(digest: &CorpusDigest, at: String) -> Self {
72        Self {
73            digest: digest.combined.clone(),
74            generation: digest.generation,
75            issues: digest.issues,
76            projects: digest
77                .projects
78                .iter()
79                .map(|p| (p.project.clone(), p.digest.clone()))
80                .collect(),
81            at,
82        }
83    }
84
85    /// The stamp body, without the comment markers a format wraps it in.
86    ///
87    /// Written without spaces inside any field so the line splits on
88    /// whitespace.
89    pub fn render(&self) -> String {
90        let projects = self
91            .projects
92            .iter()
93            .map(|(name, digest)| format!("{name}:{digest}"))
94            .collect::<Vec<_>>()
95            .join(",");
96        format!(
97            "SYNC: digest={} generation={} issues={} at={} projects={}",
98            self.digest, self.generation, self.issues, self.at, projects
99        )
100    }
101
102    /// Read a stamp from a line of a mirror, whatever comment syntax wraps it.
103    pub fn parse(line: &str) -> Option<Self> {
104        let body = line
105            .trim()
106            .trim_start_matches("<!--")
107            .trim_end_matches("-->")
108            .trim()
109            .trim_start_matches('#')
110            .trim();
111        let rest = body.strip_prefix("SYNC:")?;
112
113        let mut digest = None;
114        let mut generation = None;
115        let mut issues = None;
116        let mut at = None;
117        let mut projects = Vec::new();
118        for field in rest.split_whitespace() {
119            let (key, value) = field.split_once('=')?;
120            match key {
121                "digest" => digest = Some(value.to_string()),
122                "generation" => generation = value.parse().ok(),
123                "issues" => issues = value.parse().ok(),
124                "at" => at = Some(value.to_string()),
125                "projects" => {
126                    for entry in value.split(',').filter(|e| !e.is_empty()) {
127                        let (name, sub) = entry.split_once(':')?;
128                        projects.push((name.to_string(), sub.to_string()));
129                    }
130                }
131                _ => {}
132            }
133        }
134        Some(Self {
135            digest: digest?,
136            generation: generation?,
137            issues: issues?,
138            projects,
139            at: at?,
140        })
141    }
142
143    /// Find the stamp in a whole mirror file.
144    pub fn find(text: &str) -> Option<Self> {
145        text.lines().find_map(Self::parse)
146    }
147}
148
149/// The stamp for the current state of the named projects.
150///
151/// # Errors
152///
153/// Returns an error if the corpus cannot be read or digested.
154pub fn stamp_for(layout: &Layout, projects: &[String]) -> Result<SyncStamp> {
155    let digest = corpus_digest(layout, projects)?;
156    Ok(SyncStamp::from_digest(
157        &digest,
158        Local::now().format("%Y-%m-%dT%H:%M").to_string(),
159    ))
160}
161
162/// The verdict of comparing a mirror's stamp against the tracker.
163#[derive(Debug, Clone)]
164pub struct Freshness {
165    /// Whether the stamp's digest still matches the tracker.
166    pub fresh: bool,
167    /// Human-readable verdict, including which projects moved when stale.
168    pub report: String,
169}
170
171/// Compare the stamp inside `path` against the corpus it claims to mirror.
172///
173/// With no explicit `projects`, the stamp's own project list is used: the file
174/// records what it covered, so a caller need not repeat it.
175///
176/// # Errors
177///
178/// Returns an error if `path` cannot be read, or the corpus cannot be
179/// digested.
180pub fn check(layout: &Layout, path: &Path, projects: &[String]) -> Result<Freshness> {
181    let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
182    let Some(stamped) = SyncStamp::find(&text) else {
183        return Ok(Freshness {
184            fresh: false,
185            report: format!(
186                "stale: {} carries no SYNC stamp; regenerate it with `vissue mirror`\n",
187                path.display()
188            ),
189        });
190    };
191
192    let selected: Vec<String> = if projects.is_empty() {
193        stamped.projects.iter().map(|(n, _)| n.clone()).collect()
194    } else {
195        projects.to_vec()
196    };
197    let current = corpus_digest(layout, &selected)?;
198
199    if current.combined == stamped.digest {
200        return Ok(Freshness {
201            fresh: true,
202            report: format!(
203                "fresh: digest={} issues={} generation={} (stamped {})\n",
204                current.combined, current.issues, current.generation, stamped.at
205            ),
206        });
207    }
208
209    let mut report = format!(
210        "stale: {}\n  stamped digest={} at={} issues={}\n  current digest={} issues={} generation={}\n",
211        path.display(),
212        stamped.digest,
213        stamped.at,
214        stamped.issues,
215        current.combined,
216        current.issues,
217        current.generation
218    );
219    for (name, was) in &stamped.projects {
220        match current.digest_of(name) {
221            Some(now) if now == was => {}
222            Some(now) => {
223                let _ = writeln!(report, "  moved: {name} {was} -> {now}");
224            }
225            None => {
226                let _ = writeln!(report, "  gone:  {name} was {was}");
227            }
228        }
229    }
230    for name in current.project_names() {
231        if !stamped.projects.iter().any(|(n, _)| n == &name) {
232            let _ = writeln!(
233                report,
234                "  added: {name} {}",
235                current.digest_of(&name).unwrap_or("?")
236            );
237        }
238    }
239    Ok(Freshness {
240        fresh: false,
241        report,
242    })
243}
244
245/// Project the named projects into one document. An empty `projects` list
246/// covers every project in the layout.
247///
248/// # Errors
249///
250/// Returns an error if the corpus cannot be listed, read, or digested.
251pub fn render(
252    layout: &Layout,
253    projects: &[String],
254    format: Format,
255    state_filter: Option<&str>,
256) -> Result<String> {
257    let selected: Vec<String> = if projects.is_empty() {
258        list_projects(layout)?
259    } else {
260        let mut v = projects.to_vec();
261        v.sort();
262        v.dedup();
263        v
264    };
265
266    let stamp = stamp_for(layout, &selected)?.render();
267
268    let mut out = String::new();
269    match format {
270        Format::Org => {
271            writeln!(out, "#+TITLE: vissue mirror")?;
272            writeln!(out, "#+DATE: {}", today_inactive_bracket())?;
273            writeln!(out, "#+FILETAGS: :vissue:mirror:")?;
274            writeln!(out, "{TODO_HEADER}")?;
275            writeln!(out, "# {BANNER}")?;
276            writeln!(out, "# Projects: {}", selected.join(", "))?;
277            writeln!(out, "# {stamp}")?;
278            writeln!(out)?;
279        }
280        Format::Markdown => {
281            writeln!(out, "# vissue mirror")?;
282            writeln!(out)?;
283            writeln!(out, "_{BANNER}_")?;
284            writeln!(out)?;
285            writeln!(
286                out,
287                "Generated {} for: {}",
288                today_inactive_bracket(),
289                selected.join(", ")
290            )?;
291            writeln!(out)?;
292            writeln!(out, "<!-- {stamp} -->")?;
293            writeln!(out)?;
294        }
295    }
296
297    for project in &selected {
298        let path = layout.project_issues_path(project);
299        let doc = IssueDoc::parse_file(project, &path)?;
300        let mut headings: Vec<&IssueHeading> = doc
301            .headings
302            .iter()
303            .filter(|h| state_filter.map(|s| h.state == s).unwrap_or(true))
304            .collect();
305        headings.sort_by(|a, b| {
306            a.priority
307                .cmp(&b.priority)
308                .then_with(|| a.state.cmp(&b.state))
309                .then_with(|| a.id.cmp(&b.id))
310        });
311        if headings.is_empty() {
312            continue;
313        }
314        match format {
315            Format::Org => {
316                writeln!(out, "* {project}")?;
317                for h in headings {
318                    render_org_issue(&mut out, h)?;
319                }
320            }
321            Format::Markdown => {
322                writeln!(out, "## {project}")?;
323                writeln!(out)?;
324                for h in headings {
325                    render_markdown_issue(&mut out, h)?;
326                }
327            }
328        }
329    }
330    Ok(out)
331}
332
333/// Issues render at level two, so a body heading has to sit at level three or
334/// deeper. Without the shift, a body that opens with `** Scope` becomes a
335/// sibling of the issues and the projection's outline is wrong.
336const ISSUE_LEVEL: usize = 2;
337
338fn render_org_issue(out: &mut String, h: &IssueHeading) -> Result<()> {
339    // The projection is an Org file someone opens in Emacs, so it carries the
340    // dates and tags the same way the tracker does: on the planning line and
341    // the heading, where Org's agenda and tag search read them.
342    let stem = format!("** {} [#{}] {}", h.state, h.priority, h.title);
343    writeln!(out, "{}", crate::model::align_tags(&stem, &h.org_tags))?;
344    let planning: Vec<String> = crate::model::PLANNING_KEYS
345        .iter()
346        .filter_map(|key| {
347            let value = h.properties.get(*key)?.trim();
348            (!value.is_empty()).then(|| format!("{key}: {value}"))
349        })
350        .collect();
351    if !planning.is_empty() {
352        writeln!(out, "{}", planning.join(" "))?;
353    }
354    writeln!(out, ":PROPERTIES:")?;
355    writeln!(out, "{}", property_line("ID", &h.id))?;
356    for key in [
357        "PARENT",
358        "BLOCKED_BY",
359        crate::model::TAGS_PROPERTY,
360        "TYPE",
361        "CLAIMED_BY",
362        "CLAIMED_AT",
363    ] {
364        if let Some(val) = h.properties.get(key) {
365            writeln!(out, "{}", property_line(key, val))?;
366        }
367    }
368    writeln!(out, ":END:")?;
369    let body = demote_headings(&compact_body(&h.body));
370    if !body.is_empty() {
371        writeln!(out)?;
372        writeln!(out, "{body}")?;
373    }
374    Ok(())
375}
376
377/// `:KEY:` padded to the column the tracker's own writer uses.
378fn property_line(key: &str, value: &str) -> String {
379    let name = format!(":{key}:");
380    let pad = 13usize.saturating_sub(name.len()).max(1);
381    format!("{name}{}{value}", " ".repeat(pad))
382}
383
384/// Push every heading in a body below the issue that owns it, preserving the
385/// relative nesting the author wrote.
386fn demote_headings(body: &str) -> String {
387    let shallowest = body
388        .lines()
389        .filter_map(heading_level)
390        .min()
391        .unwrap_or(usize::MAX);
392    if shallowest > ISSUE_LEVEL {
393        return body.to_string();
394    }
395    let shift = ISSUE_LEVEL + 1 - shallowest;
396    body.lines()
397        .map(|line| {
398            if heading_level(line).is_some() {
399                format!("{}{}", "*".repeat(shift), line)
400            } else {
401                line.to_string()
402            }
403        })
404        .collect::<Vec<_>>()
405        .join("\n")
406}
407
408/// The number of leading stars, when the line is an org heading.
409fn heading_level(line: &str) -> Option<usize> {
410    let stars = line.chars().take_while(|c| *c == '*').count();
411    if stars > 0 && line.chars().nth(stars) == Some(' ') {
412        Some(stars)
413    } else {
414        None
415    }
416}
417
418fn render_markdown_issue(out: &mut String, h: &IssueHeading) -> Result<()> {
419    writeln!(out, "### {} [#{}] {}", h.state, h.priority, h.title)?;
420    writeln!(out)?;
421    writeln!(out, "- id: `{}`", h.id)?;
422    let tags = h.tags();
423    if !tags.is_empty() {
424        writeln!(out, "- tags: {}", tags.join(","))?;
425    }
426    for key in [
427        "PARENT",
428        "BLOCKED_BY",
429        "DEADLINE",
430        "SCHEDULED",
431        "TYPE",
432        "CLAIMED_BY",
433        "CLAIMED_AT",
434    ] {
435        if let Some(val) = h.properties.get(key) {
436            writeln!(out, "- {}: {}", key.to_lowercase(), val)?;
437        }
438    }
439    let body = compact_body(&h.body);
440    if !body.is_empty() {
441        writeln!(out)?;
442        writeln!(out, "{body}")?;
443    }
444    writeln!(out)?;
445    Ok(())
446}
447
448/// Collapse blank runs and stop after [`BODY_LINES`], marking the cut.
449fn compact_body(body: &str) -> String {
450    let mut kept: Vec<&str> = Vec::new();
451    let mut previous_blank = false;
452    let mut truncated = false;
453    for line in body.lines() {
454        let blank = line.trim().is_empty();
455        if blank && (previous_blank || kept.is_empty()) {
456            continue;
457        }
458        if kept.len() >= BODY_LINES {
459            truncated = true;
460            break;
461        }
462        kept.push(line);
463        previous_blank = blank;
464    }
465    while kept.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
466        kept.pop();
467    }
468    let mut text = kept.join("\n");
469    if truncated {
470        text.push_str("\n(...)");
471    }
472    text
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::config::DEFAULT_PREFIX;
479    use crate::ops::{CreateOpts, create};
480    use std::fs;
481
482    fn seeded_layout() -> (tempfile::TempDir, Layout) {
483        let dir = tempfile::tempdir().unwrap();
484        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
485        fs::create_dir_all(layout.projects_dir()).unwrap();
486        create(
487            &layout,
488            "alpha",
489            "wire the parser",
490            CreateOpts {
491                priority: Some('A'),
492                tags: Some("parser,core"),
493                body: Some("Scope: the front end.\n\n\nDone-when: it round-trips."),
494                ..Default::default()
495            },
496        )
497        .unwrap();
498        create(&layout, "beta", "other project work", CreateOpts::default()).unwrap();
499        (dir, layout)
500    }
501
502    #[test]
503    fn org_mirror_carries_the_banner_and_selected_projects_only() {
504        let (_dir, layout) = seeded_layout();
505        let text = render(&layout, &["alpha".to_string()], Format::Org, None).unwrap();
506        assert!(
507            text.contains("# MIRROR: generated by `vissue mirror`"),
508            "{text}"
509        );
510        assert!(text.contains("# Projects: alpha"), "{text}");
511        assert!(text.contains("* alpha"), "{text}");
512        assert!(!text.contains("* beta"), "{text}");
513        assert!(text.contains("** TODO [#A] wire the parser"), "{text}");
514        // Tags ride the heading, which is where Org's tag search reads them.
515        let heading = text
516            .lines()
517            .find(|l| l.starts_with("** TODO [#A] wire the parser"))
518            .expect("issue heading");
519        assert!(heading.ends_with(":parser:core:"), "{heading:?}");
520        assert!(text.contains("Scope: the front end."), "{text}");
521    }
522
523    #[test]
524    fn an_empty_project_list_covers_every_project() {
525        let (_dir, layout) = seeded_layout();
526        let text = render(&layout, &[], Format::Org, None).unwrap();
527        assert!(text.contains("* alpha"), "{text}");
528        assert!(text.contains("* beta"), "{text}");
529        assert!(text.contains("# Projects: alpha, beta"), "{text}");
530    }
531
532    #[test]
533    fn the_mirror_reparses_as_issue_headings() {
534        let (_dir, layout) = seeded_layout();
535        let text = render(&layout, &[], Format::Org, None).unwrap();
536        // Level-two mirror headings are not top-level issues, so a reparse of
537        // the projection finds the project headings and no issue bodies.
538        let doc = IssueDoc::parse("mirror", std::path::PathBuf::from("/tmp/m.org"), &text);
539        assert!(doc.is_err(), "project headings carry no :ID: property");
540    }
541
542    #[test]
543    fn markdown_mirror_lists_metadata_as_bullets() {
544        let (_dir, layout) = seeded_layout();
545        let text = render(&layout, &["alpha".to_string()], Format::Markdown, None).unwrap();
546        assert!(text.contains("### TODO [#A] wire the parser"), "{text}");
547        assert!(text.contains("- tags: parser,core"), "{text}");
548    }
549
550    #[test]
551    fn state_filter_selects_a_single_bucket() {
552        let (_dir, layout) = seeded_layout();
553        let text = render(&layout, &[], Format::Org, Some("DONE")).unwrap();
554        assert!(!text.contains("** TODO"), "{text}");
555        assert!(text.contains("# Projects: alpha, beta"), "{text}");
556    }
557
558    #[test]
559    fn body_compaction_collapses_blanks_and_marks_the_cut() {
560        let long: String = (1..=20).map(|i| format!("line {i}\n")).collect();
561        let compacted = compact_body(&long);
562        assert_eq!(compacted.lines().count(), BODY_LINES + 1);
563        assert!(compacted.ends_with("(...)"), "{compacted}");
564        assert_eq!(compact_body("a\n\n\n\nb"), "a\n\nb");
565        assert_eq!(compact_body("\n\n"), "");
566    }
567
568    #[test]
569    fn body_headings_sit_below_the_issue_that_owns_them() {
570        // A body written with level-two headings would otherwise render as a
571        // sibling of the issues, so the outline would claim Scope is an issue.
572        assert_eq!(
573            demote_headings("** Scope\ntext\n*** Detail"),
574            "*** Scope\ntext\n**** Detail"
575        );
576        assert_eq!(demote_headings("* Top\n** Under"), "*** Top\n**** Under");
577        assert_eq!(
578            demote_headings("**** Already deep"),
579            "**** Already deep",
580            "a body that is already nested is left alone"
581        );
582        assert_eq!(demote_headings("no headings here"), "no headings here");
583        assert_eq!(
584            demote_headings("*bold* not a heading"),
585            "*bold* not a heading",
586            "a star without a following space is not a heading"
587        );
588    }
589
590    #[test]
591    fn a_mirrored_body_heading_never_reparses_as_an_issue() {
592        let dir = tempfile::tempdir().unwrap();
593        let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
594        fs::create_dir_all(layout.projects_dir()).unwrap();
595        create(
596            &layout,
597            "alpha",
598            "structured body",
599            CreateOpts {
600                body: Some("** Scope\nthe front end.\n** Done when\nit round-trips."),
601                ..Default::default()
602            },
603        )
604        .unwrap();
605        let text = render(&layout, &[], Format::Org, None).unwrap();
606        assert!(text.contains("*** Scope"), "{text}");
607        assert!(text.contains("*** Done when"), "{text}");
608        assert!(
609            !text.contains("\n** Scope"),
610            "a body heading kept issue level: {text}"
611        );
612    }
613
614    #[test]
615    fn property_lines_line_up_with_the_tracker_format() {
616        assert_eq!(property_line("ID", "alpha-1a2b"), ":ID:         alpha-1a2b");
617        assert_eq!(
618            property_line("PARENT", "alpha-9z8y"),
619            ":PARENT:     alpha-9z8y"
620        );
621        assert_eq!(
622            property_line("BLOCKED_BY", "alpha-1"),
623            ":BLOCKED_BY: alpha-1"
624        );
625    }
626
627    #[test]
628    fn a_stamp_round_trips_through_its_rendered_form() {
629        let stamp = SyncStamp {
630            digest: "0123456789abcdef".into(),
631            generation: 3167,
632            issues: 13,
633            projects: vec![
634                ("alpha".into(), "aaaaaaaaaaaaaaaa".into()),
635                ("beta".into(), "bbbbbbbbbbbbbbbb".into()),
636            ],
637            at: "2026-08-03T09:30".into(),
638        };
639        let line = stamp.render();
640        assert!(line.starts_with("SYNC: digest=0123456789abcdef"), "{line}");
641        assert!(
642            line.contains("projects=alpha:aaaaaaaaaaaaaaaa,beta:bbbbbbbbbbbbbbbb"),
643            "{line}"
644        );
645
646        // The comment wrappers each format uses must both parse back.
647        assert_eq!(SyncStamp::parse(&format!("# {line}")).unwrap(), stamp);
648        assert_eq!(
649            SyncStamp::parse(&format!("<!-- {line} -->")).unwrap(),
650            stamp
651        );
652        assert_eq!(SyncStamp::parse(&line).unwrap(), stamp);
653    }
654
655    #[test]
656    fn a_line_that_is_not_a_stamp_parses_as_nothing() {
657        for line in [
658            "# MIRROR: generated by `vissue mirror`.",
659            "# Projects: alpha, beta",
660            "* alpha",
661            "",
662        ] {
663            assert!(SyncStamp::parse(line).is_none(), "{line}");
664        }
665    }
666
667    #[test]
668    fn the_stamp_is_found_in_a_rendered_mirror() {
669        let (_dir, layout) = seeded_layout();
670        let text = render(&layout, &[], Format::Org, None).unwrap();
671        let stamp = SyncStamp::find(&text).expect("no stamp in the mirror header");
672        let current = crate::digest::corpus_digest(&layout, &[]).unwrap();
673        assert_eq!(stamp.digest, current.combined);
674        assert_eq!(stamp.issues, current.issues);
675        assert_eq!(stamp.projects.len(), 2);
676
677        let markdown = render(&layout, &[], Format::Markdown, None).unwrap();
678        assert_eq!(SyncStamp::find(&markdown).unwrap().digest, current.combined);
679    }
680
681    #[test]
682    fn format_parsing_rejects_unknown_names() {
683        assert_eq!(Format::parse("org").unwrap(), Format::Org);
684        assert_eq!(Format::parse("md").unwrap(), Format::Markdown);
685        assert!(Format::parse("pdf").is_err());
686    }
687}