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