1use 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
17pub const BODY_LINES: usize = 12;
19
20const BANNER: &str =
21 "MIRROR: generated by `vissue mirror`. Read-only projection; edits here are overwritten.";
22
23#[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#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct SyncStamp {
47 pub digest: String,
48 pub generation: u64,
49 pub issues: usize,
50 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 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 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 pub fn find(text: &str) -> Option<Self> {
131 text.lines().find_map(Self::parse)
132 }
133}
134
135pub 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#[derive(Debug, Clone)]
146pub struct Freshness {
147 pub fresh: bool,
148 pub report: String,
149}
150
151pub 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
220pub 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
304const ISSUE_LEVEL: usize = 2;
308
309fn render_org_issue(out: &mut String, h: &IssueHeading) -> Result<()> {
310 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
348fn 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
355fn 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
379fn 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
419fn 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 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 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 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 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}