use anyhow::{bail, Context, Result};
use chrono::Local;
use std::fmt::Write as _;
use std::path::Path;
use crate::config::Layout;
use crate::digest::{corpus_digest, CorpusDigest};
use crate::model::{today_inactive_bracket, IssueHeading, TODO_HEADER};
use crate::store::{list_projects, IssueDoc};
pub const BODY_LINES: usize = 12;
const BANNER: &str =
"MIRROR: generated by `vissue mirror`. Read-only projection; edits here are overwritten.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Org,
Markdown,
}
impl Format {
pub fn parse(s: &str) -> Result<Self> {
match s {
"org" => Ok(Format::Org),
"markdown" | "md" => Ok(Format::Markdown),
other => bail!("unknown format {other:?}; allowed: org, markdown"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyncStamp {
pub digest: String,
pub generation: u64,
pub issues: usize,
pub projects: Vec<(String, String)>,
pub at: String,
}
impl SyncStamp {
pub fn from_digest(digest: &CorpusDigest, at: String) -> Self {
Self {
digest: digest.combined.clone(),
generation: digest.generation,
issues: digest.issues,
projects: digest
.projects
.iter()
.map(|p| (p.project.clone(), p.digest.clone()))
.collect(),
at,
}
}
pub fn render(&self) -> String {
let projects = self
.projects
.iter()
.map(|(name, digest)| format!("{name}:{digest}"))
.collect::<Vec<_>>()
.join(",");
format!(
"SYNC: digest={} generation={} issues={} at={} projects={}",
self.digest, self.generation, self.issues, self.at, projects
)
}
pub fn parse(line: &str) -> Option<Self> {
let body = line
.trim()
.trim_start_matches("<!--")
.trim_end_matches("-->")
.trim()
.trim_start_matches('#')
.trim();
let rest = body.strip_prefix("SYNC:")?;
let mut digest = None;
let mut generation = None;
let mut issues = None;
let mut at = None;
let mut projects = Vec::new();
for field in rest.split_whitespace() {
let (key, value) = field.split_once('=')?;
match key {
"digest" => digest = Some(value.to_string()),
"generation" => generation = value.parse().ok(),
"issues" => issues = value.parse().ok(),
"at" => at = Some(value.to_string()),
"projects" => {
for entry in value.split(',').filter(|e| !e.is_empty()) {
let (name, sub) = entry.split_once(':')?;
projects.push((name.to_string(), sub.to_string()));
}
}
_ => {}
}
}
Some(Self {
digest: digest?,
generation: generation?,
issues: issues?,
projects,
at: at?,
})
}
pub fn find(text: &str) -> Option<Self> {
text.lines().find_map(Self::parse)
}
}
pub fn stamp_for(layout: &Layout, projects: &[String]) -> Result<SyncStamp> {
let digest = corpus_digest(layout, projects)?;
Ok(SyncStamp::from_digest(
&digest,
Local::now().format("%Y-%m-%dT%H:%M").to_string(),
))
}
#[derive(Debug, Clone)]
pub struct Freshness {
pub fresh: bool,
pub report: String,
}
pub fn check(layout: &Layout, path: &Path, projects: &[String]) -> Result<Freshness> {
let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let Some(stamped) = SyncStamp::find(&text) else {
return Ok(Freshness {
fresh: false,
report: format!(
"stale: {} carries no SYNC stamp; regenerate it with `vissue mirror`\n",
path.display()
),
});
};
let selected: Vec<String> = if projects.is_empty() {
stamped.projects.iter().map(|(n, _)| n.clone()).collect()
} else {
projects.to_vec()
};
let current = corpus_digest(layout, &selected)?;
if current.combined == stamped.digest {
return Ok(Freshness {
fresh: true,
report: format!(
"fresh: digest={} issues={} generation={} (stamped {})\n",
current.combined, current.issues, current.generation, stamped.at
),
});
}
let mut report = format!(
"stale: {}\n stamped digest={} at={} issues={}\n current digest={} issues={} generation={}\n",
path.display(),
stamped.digest,
stamped.at,
stamped.issues,
current.combined,
current.issues,
current.generation
);
for (name, was) in &stamped.projects {
match current.digest_of(name) {
Some(now) if now == was => {}
Some(now) => {
let _ = writeln!(report, " moved: {name} {was} -> {now}");
}
None => {
let _ = writeln!(report, " gone: {name} was {was}");
}
}
}
for name in current.project_names() {
if !stamped.projects.iter().any(|(n, _)| n == &name) {
let _ = writeln!(
report,
" added: {name} {}",
current.digest_of(&name).unwrap_or("?")
);
}
}
Ok(Freshness {
fresh: false,
report,
})
}
pub fn render(
layout: &Layout,
projects: &[String],
format: Format,
state_filter: Option<&str>,
) -> Result<String> {
let selected: Vec<String> = if projects.is_empty() {
list_projects(layout)?
} else {
let mut v = projects.to_vec();
v.sort();
v.dedup();
v
};
let stamp = stamp_for(layout, &selected)?.render();
let mut out = String::new();
match format {
Format::Org => {
writeln!(out, "#+TITLE: vissue mirror")?;
writeln!(out, "#+DATE: {}", today_inactive_bracket())?;
writeln!(out, "#+FILETAGS: :vissue:mirror:")?;
writeln!(out, "{TODO_HEADER}")?;
writeln!(out, "# {BANNER}")?;
writeln!(out, "# Projects: {}", selected.join(", "))?;
writeln!(out, "# {stamp}")?;
writeln!(out)?;
}
Format::Markdown => {
writeln!(out, "# vissue mirror")?;
writeln!(out)?;
writeln!(out, "_{BANNER}_")?;
writeln!(out)?;
writeln!(
out,
"Generated {} for: {}",
today_inactive_bracket(),
selected.join(", ")
)?;
writeln!(out)?;
writeln!(out, "<!-- {stamp} -->")?;
writeln!(out)?;
}
}
for project in &selected {
let path = layout.project_issues_path(project);
let doc = IssueDoc::parse_file(project, &path)?;
let mut headings: Vec<&IssueHeading> = doc
.headings
.iter()
.filter(|h| state_filter.map(|s| h.state == s).unwrap_or(true))
.collect();
headings.sort_by(|a, b| {
a.priority
.cmp(&b.priority)
.then_with(|| a.state.cmp(&b.state))
.then_with(|| a.id.cmp(&b.id))
});
if headings.is_empty() {
continue;
}
match format {
Format::Org => {
writeln!(out, "* {project}")?;
for h in headings {
render_org_issue(&mut out, h)?;
}
}
Format::Markdown => {
writeln!(out, "## {project}")?;
writeln!(out)?;
for h in headings {
render_markdown_issue(&mut out, h)?;
}
}
}
}
Ok(out)
}
const ISSUE_LEVEL: usize = 2;
fn render_org_issue(out: &mut String, h: &IssueHeading) -> Result<()> {
let stem = format!("** {} [#{}] {}", h.state, h.priority, h.title);
writeln!(out, "{}", crate::model::align_tags(&stem, &h.org_tags))?;
let planning: Vec<String> = crate::model::PLANNING_KEYS
.iter()
.filter_map(|key| {
let value = h.properties.get(*key)?.trim();
(!value.is_empty()).then(|| format!("{key}: {value}"))
})
.collect();
if !planning.is_empty() {
writeln!(out, "{}", planning.join(" "))?;
}
writeln!(out, ":PROPERTIES:")?;
writeln!(out, "{}", property_line("ID", &h.id))?;
for key in [
"PARENT",
"BLOCKED_BY",
crate::model::TAGS_PROPERTY,
"TYPE",
"CLAIMED_BY",
"CLAIMED_AT",
] {
if let Some(val) = h.properties.get(key) {
writeln!(out, "{}", property_line(key, val))?;
}
}
writeln!(out, ":END:")?;
let body = demote_headings(&compact_body(&h.body));
if !body.is_empty() {
writeln!(out)?;
writeln!(out, "{body}")?;
}
Ok(())
}
fn property_line(key: &str, value: &str) -> String {
let name = format!(":{key}:");
let pad = 13usize.saturating_sub(name.len()).max(1);
format!("{name}{}{value}", " ".repeat(pad))
}
fn demote_headings(body: &str) -> String {
let shallowest = body
.lines()
.filter_map(heading_level)
.min()
.unwrap_or(usize::MAX);
if shallowest > ISSUE_LEVEL {
return body.to_string();
}
let shift = ISSUE_LEVEL + 1 - shallowest;
body.lines()
.map(|line| {
if heading_level(line).is_some() {
format!("{}{}", "*".repeat(shift), line)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn heading_level(line: &str) -> Option<usize> {
let stars = line.chars().take_while(|c| *c == '*').count();
if stars > 0 && line.chars().nth(stars) == Some(' ') {
Some(stars)
} else {
None
}
}
fn render_markdown_issue(out: &mut String, h: &IssueHeading) -> Result<()> {
writeln!(out, "### {} [#{}] {}", h.state, h.priority, h.title)?;
writeln!(out)?;
writeln!(out, "- id: `{}`", h.id)?;
let tags = h.tags();
if !tags.is_empty() {
writeln!(out, "- tags: {}", tags.join(","))?;
}
for key in [
"PARENT",
"BLOCKED_BY",
"DEADLINE",
"SCHEDULED",
"TYPE",
"CLAIMED_BY",
"CLAIMED_AT",
] {
if let Some(val) = h.properties.get(key) {
writeln!(out, "- {}: {}", key.to_lowercase(), val)?;
}
}
let body = compact_body(&h.body);
if !body.is_empty() {
writeln!(out)?;
writeln!(out, "{body}")?;
}
writeln!(out)?;
Ok(())
}
fn compact_body(body: &str) -> String {
let mut kept: Vec<&str> = Vec::new();
let mut previous_blank = false;
let mut truncated = false;
for line in body.lines() {
let blank = line.trim().is_empty();
if blank && (previous_blank || kept.is_empty()) {
continue;
}
if kept.len() >= BODY_LINES {
truncated = true;
break;
}
kept.push(line);
previous_blank = blank;
}
while kept.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
kept.pop();
}
let mut text = kept.join("\n");
if truncated {
text.push_str("\n(...)");
}
text
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DEFAULT_PREFIX;
use crate::ops::{create, CreateOpts};
use std::fs;
fn seeded_layout() -> (tempfile::TempDir, Layout) {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
fs::create_dir_all(layout.projects_dir()).unwrap();
create(
&layout,
"alpha",
"wire the parser",
CreateOpts {
priority: Some('A'),
tags: Some("parser,core"),
body: Some("Scope: the front end.\n\n\nDone-when: it round-trips."),
..Default::default()
},
)
.unwrap();
create(&layout, "beta", "other project work", CreateOpts::default()).unwrap();
(dir, layout)
}
#[test]
fn org_mirror_carries_the_banner_and_selected_projects_only() {
let (_dir, layout) = seeded_layout();
let text = render(&layout, &["alpha".to_string()], Format::Org, None).unwrap();
assert!(
text.contains("# MIRROR: generated by `vissue mirror`"),
"{text}"
);
assert!(text.contains("# Projects: alpha"), "{text}");
assert!(text.contains("* alpha"), "{text}");
assert!(!text.contains("* beta"), "{text}");
assert!(text.contains("** TODO [#A] wire the parser"), "{text}");
let heading = text
.lines()
.find(|l| l.starts_with("** TODO [#A] wire the parser"))
.expect("issue heading");
assert!(heading.ends_with(":parser:core:"), "{heading:?}");
assert!(text.contains("Scope: the front end."), "{text}");
}
#[test]
fn an_empty_project_list_covers_every_project() {
let (_dir, layout) = seeded_layout();
let text = render(&layout, &[], Format::Org, None).unwrap();
assert!(text.contains("* alpha"), "{text}");
assert!(text.contains("* beta"), "{text}");
assert!(text.contains("# Projects: alpha, beta"), "{text}");
}
#[test]
fn the_mirror_reparses_as_issue_headings() {
let (_dir, layout) = seeded_layout();
let text = render(&layout, &[], Format::Org, None).unwrap();
let doc = IssueDoc::parse("mirror", std::path::PathBuf::from("/tmp/m.org"), &text);
assert!(doc.is_err(), "project headings carry no :ID: property");
}
#[test]
fn markdown_mirror_lists_metadata_as_bullets() {
let (_dir, layout) = seeded_layout();
let text = render(&layout, &["alpha".to_string()], Format::Markdown, None).unwrap();
assert!(text.contains("### TODO [#A] wire the parser"), "{text}");
assert!(text.contains("- tags: parser,core"), "{text}");
}
#[test]
fn state_filter_selects_a_single_bucket() {
let (_dir, layout) = seeded_layout();
let text = render(&layout, &[], Format::Org, Some("DONE")).unwrap();
assert!(!text.contains("** TODO"), "{text}");
assert!(text.contains("# Projects: alpha, beta"), "{text}");
}
#[test]
fn body_compaction_collapses_blanks_and_marks_the_cut() {
let long: String = (1..=20).map(|i| format!("line {i}\n")).collect();
let compacted = compact_body(&long);
assert_eq!(compacted.lines().count(), BODY_LINES + 1);
assert!(compacted.ends_with("(...)"), "{compacted}");
assert_eq!(compact_body("a\n\n\n\nb"), "a\n\nb");
assert_eq!(compact_body("\n\n"), "");
}
#[test]
fn body_headings_sit_below_the_issue_that_owns_them() {
assert_eq!(
demote_headings("** Scope\ntext\n*** Detail"),
"*** Scope\ntext\n**** Detail"
);
assert_eq!(demote_headings("* Top\n** Under"), "*** Top\n**** Under");
assert_eq!(
demote_headings("**** Already deep"),
"**** Already deep",
"a body that is already nested is left alone"
);
assert_eq!(demote_headings("no headings here"), "no headings here");
assert_eq!(
demote_headings("*bold* not a heading"),
"*bold* not a heading",
"a star without a following space is not a heading"
);
}
#[test]
fn a_mirrored_body_heading_never_reparses_as_an_issue() {
let dir = tempfile::tempdir().unwrap();
let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
fs::create_dir_all(layout.projects_dir()).unwrap();
create(
&layout,
"alpha",
"structured body",
CreateOpts {
body: Some("** Scope\nthe front end.\n** Done when\nit round-trips."),
..Default::default()
},
)
.unwrap();
let text = render(&layout, &[], Format::Org, None).unwrap();
assert!(text.contains("*** Scope"), "{text}");
assert!(text.contains("*** Done when"), "{text}");
assert!(
!text.contains("\n** Scope"),
"a body heading kept issue level: {text}"
);
}
#[test]
fn property_lines_line_up_with_the_tracker_format() {
assert_eq!(property_line("ID", "alpha-1a2b"), ":ID: alpha-1a2b");
assert_eq!(
property_line("PARENT", "alpha-9z8y"),
":PARENT: alpha-9z8y"
);
assert_eq!(
property_line("BLOCKED_BY", "alpha-1"),
":BLOCKED_BY: alpha-1"
);
}
#[test]
fn a_stamp_round_trips_through_its_rendered_form() {
let stamp = SyncStamp {
digest: "0123456789abcdef".into(),
generation: 3167,
issues: 13,
projects: vec![
("alpha".into(), "aaaaaaaaaaaaaaaa".into()),
("beta".into(), "bbbbbbbbbbbbbbbb".into()),
],
at: "2026-08-03T09:30".into(),
};
let line = stamp.render();
assert!(line.starts_with("SYNC: digest=0123456789abcdef"), "{line}");
assert!(
line.contains("projects=alpha:aaaaaaaaaaaaaaaa,beta:bbbbbbbbbbbbbbbb"),
"{line}"
);
assert_eq!(SyncStamp::parse(&format!("# {line}")).unwrap(), stamp);
assert_eq!(
SyncStamp::parse(&format!("<!-- {line} -->")).unwrap(),
stamp
);
assert_eq!(SyncStamp::parse(&line).unwrap(), stamp);
}
#[test]
fn a_line_that_is_not_a_stamp_parses_as_nothing() {
for line in [
"# MIRROR: generated by `vissue mirror`.",
"# Projects: alpha, beta",
"* alpha",
"",
] {
assert!(SyncStamp::parse(line).is_none(), "{line}");
}
}
#[test]
fn the_stamp_is_found_in_a_rendered_mirror() {
let (_dir, layout) = seeded_layout();
let text = render(&layout, &[], Format::Org, None).unwrap();
let stamp = SyncStamp::find(&text).expect("no stamp in the mirror header");
let current = crate::digest::corpus_digest(&layout, &[]).unwrap();
assert_eq!(stamp.digest, current.combined);
assert_eq!(stamp.issues, current.issues);
assert_eq!(stamp.projects.len(), 2);
let markdown = render(&layout, &[], Format::Markdown, None).unwrap();
assert_eq!(SyncStamp::find(&markdown).unwrap().digest, current.combined);
}
#[test]
fn format_parsing_rejects_unknown_names() {
assert_eq!(Format::parse("org").unwrap(), Format::Org);
assert_eq!(Format::parse("md").unwrap(), Format::Markdown);
assert!(Format::parse("pdf").is_err());
}
}