1use 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
19pub const BODY_LINES: usize = 12;
21
22const BANNER: &str =
23 "MIRROR: generated by `vissue mirror`. Read-only projection; edits here are overwritten.";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Format {
28 Org,
30 Markdown,
32}
33
34impl Format {
35 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#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SyncStamp {
56 pub digest: String,
58 pub generation: u64,
60 pub issues: usize,
62 pub projects: Vec<(String, String)>,
65 pub at: String,
67}
68
69impl SyncStamp {
70 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 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 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 pub fn find(text: &str) -> Option<Self> {
145 text.lines().find_map(Self::parse)
146 }
147}
148
149pub 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#[derive(Debug, Clone)]
164pub struct Freshness {
165 pub fresh: bool,
167 pub report: String,
169}
170
171pub 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
245pub 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 .filter(|h| doc.tag_settings.heading_exportable(&h.org_tags))
305 .collect();
306 headings.sort_by(|a, b| {
307 a.priority
308 .cmp(&b.priority)
309 .then_with(|| a.state.cmp(&b.state))
310 .then_with(|| a.id.cmp(&b.id))
311 });
312 if headings.is_empty() {
313 continue;
314 }
315 match format {
316 Format::Org => {
317 writeln!(out, "* {project}")?;
318 for h in headings {
319 render_org_issue(&mut out, h)?;
320 }
321 }
322 Format::Markdown => {
323 writeln!(out, "## {project}")?;
324 writeln!(out)?;
325 for h in headings {
326 render_markdown_issue(&mut out, h)?;
327 }
328 }
329 }
330 }
331 Ok(out)
332}
333
334const ISSUE_LEVEL: usize = 2;
338
339fn render_org_issue(out: &mut String, h: &IssueHeading) -> Result<()> {
340 let stem = format!("** {} [#{}] {}", h.state, h.priority, h.title);
344 writeln!(out, "{}", crate::model::align_tags(&stem, &h.org_tags))?;
345 let planning: Vec<String> = crate::model::PLANNING_KEYS
346 .iter()
347 .filter_map(|key| {
348 let value = h.properties.get(*key)?.trim();
349 (!value.is_empty()).then(|| format!("{key}: {value}"))
350 })
351 .collect();
352 if !planning.is_empty() {
353 writeln!(out, "{}", planning.join(" "))?;
354 }
355 writeln!(out, ":PROPERTIES:")?;
356 writeln!(out, "{}", property_line("ID", &h.id))?;
357 for key in [
358 "PARENT",
359 "BLOCKED_BY",
360 crate::model::TAGS_PROPERTY,
361 "TYPE",
362 "CLAIMED_BY",
363 "CLAIMED_AT",
364 ] {
365 if let Some(val) = h.properties.get(key) {
366 writeln!(out, "{}", property_line(key, val))?;
367 }
368 }
369 writeln!(out, ":END:")?;
370 let body = demote_headings(&compact_body(&h.body));
371 if !body.is_empty() {
372 writeln!(out)?;
373 writeln!(out, "{body}")?;
374 }
375 Ok(())
376}
377
378fn property_line(key: &str, value: &str) -> String {
380 let name = format!(":{key}:");
381 let pad = 13usize.saturating_sub(name.len()).max(1);
382 format!("{name}{}{value}", " ".repeat(pad))
383}
384
385fn demote_headings(body: &str) -> String {
388 let shallowest = body
389 .lines()
390 .filter_map(heading_level)
391 .min()
392 .unwrap_or(usize::MAX);
393 if shallowest > ISSUE_LEVEL {
394 return body.to_string();
395 }
396 let shift = ISSUE_LEVEL + 1 - shallowest;
397 body.lines()
398 .map(|line| {
399 if heading_level(line).is_some() {
400 format!("{}{}", "*".repeat(shift), line)
401 } else {
402 line.to_string()
403 }
404 })
405 .collect::<Vec<_>>()
406 .join("\n")
407}
408
409fn heading_level(line: &str) -> Option<usize> {
411 let stars = line.chars().take_while(|c| *c == '*').count();
412 if stars > 0 && line.chars().nth(stars) == Some(' ') {
413 Some(stars)
414 } else {
415 None
416 }
417}
418
419fn render_markdown_issue(out: &mut String, h: &IssueHeading) -> Result<()> {
420 writeln!(out, "### {} [#{}] {}", h.state, h.priority, h.title)?;
421 writeln!(out)?;
422 writeln!(out, "- id: `{}`", h.id)?;
423 let tags = h.tags();
424 if !tags.is_empty() {
425 writeln!(out, "- tags: {}", tags.join(","))?;
426 }
427 for key in [
428 "PARENT",
429 "BLOCKED_BY",
430 "DEADLINE",
431 "SCHEDULED",
432 "TYPE",
433 "CLAIMED_BY",
434 "CLAIMED_AT",
435 ] {
436 if let Some(val) = h.properties.get(key) {
437 writeln!(out, "- {}: {}", key.to_lowercase(), val)?;
438 }
439 }
440 let body = compact_body(&h.body);
441 if !body.is_empty() {
442 writeln!(out)?;
443 writeln!(out, "{body}")?;
444 }
445 writeln!(out)?;
446 Ok(())
447}
448
449fn compact_body(body: &str) -> String {
451 let mut kept: Vec<&str> = Vec::new();
452 let mut previous_blank = false;
453 let mut truncated = false;
454 for line in body.lines() {
455 let blank = line.trim().is_empty();
456 if blank && (previous_blank || kept.is_empty()) {
457 continue;
458 }
459 if kept.len() >= BODY_LINES {
460 truncated = true;
461 break;
462 }
463 kept.push(line);
464 previous_blank = blank;
465 }
466 while kept.last().map(|l| l.trim().is_empty()).unwrap_or(false) {
467 kept.pop();
468 }
469 let mut text = kept.join("\n");
470 if truncated {
471 text.push_str("\n(...)");
472 }
473 text
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use crate::config::DEFAULT_PREFIX;
480 use crate::ops::{CreateOpts, create};
481 use std::fs;
482
483 fn seeded_layout() -> (tempfile::TempDir, Layout) {
484 let dir = tempfile::tempdir().unwrap();
485 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
486 fs::create_dir_all(layout.projects_dir()).unwrap();
487 create(
488 &layout,
489 "alpha",
490 "wire the parser",
491 CreateOpts {
492 priority: Some('A'),
493 tags: Some("parser,core"),
494 body: Some("Scope: the front end.\n\n\nDone-when: it round-trips."),
495 ..Default::default()
496 },
497 )
498 .unwrap();
499 create(&layout, "beta", "other project work", CreateOpts::default()).unwrap();
500 (dir, layout)
501 }
502
503 #[test]
504 fn org_mirror_carries_the_banner_and_selected_projects_only() {
505 let (_dir, layout) = seeded_layout();
506 let text = render(&layout, &["alpha".to_string()], Format::Org, None).unwrap();
507 assert!(
508 text.contains("# MIRROR: generated by `vissue mirror`"),
509 "{text}"
510 );
511 assert!(text.contains("# Projects: alpha"), "{text}");
512 assert!(text.contains("* alpha"), "{text}");
513 assert!(!text.contains("* beta"), "{text}");
514 assert!(text.contains("** TODO [#A] wire the parser"), "{text}");
515 let heading = text
517 .lines()
518 .find(|l| l.starts_with("** TODO [#A] wire the parser"))
519 .expect("issue heading");
520 assert!(heading.ends_with(":parser:core:"), "{heading:?}");
521 assert!(text.contains("Scope: the front end."), "{text}");
522 }
523
524 #[test]
525 fn an_empty_project_list_covers_every_project() {
526 let (_dir, layout) = seeded_layout();
527 let text = render(&layout, &[], Format::Org, None).unwrap();
528 assert!(text.contains("* alpha"), "{text}");
529 assert!(text.contains("* beta"), "{text}");
530 assert!(text.contains("# Projects: alpha, beta"), "{text}");
531 }
532
533 #[test]
534 fn the_mirror_reparses_as_issue_headings() {
535 let (_dir, layout) = seeded_layout();
536 let text = render(&layout, &[], Format::Org, None).unwrap();
537 let doc = IssueDoc::parse("mirror", std::path::PathBuf::from("/tmp/m.org"), &text)
541 .expect("a mirror is legal Org");
542 assert!(
543 doc.headings.is_empty(),
544 "project headings are sections, not issues: {:?}",
545 doc.headings.iter().map(|h| &h.id).collect::<Vec<_>>()
546 );
547 }
548
549 #[test]
550 fn markdown_mirror_lists_metadata_as_bullets() {
551 let (_dir, layout) = seeded_layout();
552 let text = render(&layout, &["alpha".to_string()], Format::Markdown, None).unwrap();
553 assert!(text.contains("### TODO [#A] wire the parser"), "{text}");
554 assert!(text.contains("- tags: parser,core"), "{text}");
555 }
556
557 #[test]
558 fn state_filter_selects_a_single_bucket() {
559 let (_dir, layout) = seeded_layout();
560 let text = render(&layout, &[], Format::Org, Some("DONE")).unwrap();
561 assert!(!text.contains("** TODO"), "{text}");
562 assert!(text.contains("# Projects: alpha, beta"), "{text}");
563 }
564
565 #[test]
566 fn body_compaction_collapses_blanks_and_marks_the_cut() {
567 let long: String = (1..=20).map(|i| format!("line {i}\n")).collect();
568 let compacted = compact_body(&long);
569 assert_eq!(compacted.lines().count(), BODY_LINES + 1);
570 assert!(compacted.ends_with("(...)"), "{compacted}");
571 assert_eq!(compact_body("a\n\n\n\nb"), "a\n\nb");
572 assert_eq!(compact_body("\n\n"), "");
573 }
574
575 #[test]
576 fn body_headings_sit_below_the_issue_that_owns_them() {
577 assert_eq!(
580 demote_headings("** Scope\ntext\n*** Detail"),
581 "*** Scope\ntext\n**** Detail"
582 );
583 assert_eq!(demote_headings("* Top\n** Under"), "*** Top\n**** Under");
584 assert_eq!(
585 demote_headings("**** Already deep"),
586 "**** Already deep",
587 "a body that is already nested is left alone"
588 );
589 assert_eq!(demote_headings("no headings here"), "no headings here");
590 assert_eq!(
591 demote_headings("*bold* not a heading"),
592 "*bold* not a heading",
593 "a star without a following space is not a heading"
594 );
595 }
596
597 #[test]
598 fn a_mirrored_body_heading_never_reparses_as_an_issue() {
599 let dir = tempfile::tempdir().unwrap();
600 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
601 fs::create_dir_all(layout.projects_dir()).unwrap();
602 create(
603 &layout,
604 "alpha",
605 "structured body",
606 CreateOpts {
607 body: Some("** Scope\nthe front end.\n** Done when\nit round-trips."),
608 ..Default::default()
609 },
610 )
611 .unwrap();
612 let text = render(&layout, &[], Format::Org, None).unwrap();
613 assert!(text.contains("*** Scope"), "{text}");
614 assert!(text.contains("*** Done when"), "{text}");
615 assert!(
616 !text.contains("\n** Scope"),
617 "a body heading kept issue level: {text}"
618 );
619 }
620
621 #[test]
622 fn property_lines_line_up_with_the_tracker_format() {
623 assert_eq!(property_line("ID", "alpha-1a2b"), ":ID: alpha-1a2b");
624 assert_eq!(
625 property_line("PARENT", "alpha-9z8y"),
626 ":PARENT: alpha-9z8y"
627 );
628 assert_eq!(
629 property_line("BLOCKED_BY", "alpha-1"),
630 ":BLOCKED_BY: alpha-1"
631 );
632 }
633
634 #[test]
635 fn a_stamp_round_trips_through_its_rendered_form() {
636 let stamp = SyncStamp {
637 digest: "0123456789abcdef".into(),
638 generation: 3167,
639 issues: 13,
640 projects: vec![
641 ("alpha".into(), "aaaaaaaaaaaaaaaa".into()),
642 ("beta".into(), "bbbbbbbbbbbbbbbb".into()),
643 ],
644 at: "2026-08-03T09:30".into(),
645 };
646 let line = stamp.render();
647 assert!(line.starts_with("SYNC: digest=0123456789abcdef"), "{line}");
648 assert!(
649 line.contains("projects=alpha:aaaaaaaaaaaaaaaa,beta:bbbbbbbbbbbbbbbb"),
650 "{line}"
651 );
652
653 assert_eq!(SyncStamp::parse(&format!("# {line}")).unwrap(), stamp);
655 assert_eq!(
656 SyncStamp::parse(&format!("<!-- {line} -->")).unwrap(),
657 stamp
658 );
659 assert_eq!(SyncStamp::parse(&line).unwrap(), stamp);
660 }
661
662 #[test]
663 fn a_line_that_is_not_a_stamp_parses_as_nothing() {
664 for line in [
665 "# MIRROR: generated by `vissue mirror`.",
666 "# Projects: alpha, beta",
667 "* alpha",
668 "",
669 ] {
670 assert!(SyncStamp::parse(line).is_none(), "{line}");
671 }
672 }
673
674 #[test]
675 fn the_stamp_is_found_in_a_rendered_mirror() {
676 let (_dir, layout) = seeded_layout();
677 let text = render(&layout, &[], Format::Org, None).unwrap();
678 let stamp = SyncStamp::find(&text).expect("no stamp in the mirror header");
679 let current = crate::digest::corpus_digest(&layout, &[]).unwrap();
680 assert_eq!(stamp.digest, current.combined);
681 assert_eq!(stamp.issues, current.issues);
682 assert_eq!(stamp.projects.len(), 2);
683
684 let markdown = render(&layout, &[], Format::Markdown, None).unwrap();
685 assert_eq!(SyncStamp::find(&markdown).unwrap().digest, current.combined);
686 }
687
688 #[test]
689 fn format_parsing_rejects_unknown_names() {
690 assert_eq!(Format::parse("org").unwrap(), Format::Org);
691 assert_eq!(Format::parse("md").unwrap(), Format::Markdown);
692 assert!(Format::parse("pdf").is_err());
693 }
694
695 #[test]
696 fn org_mirror_drops_a_heading_tagged_noexport() {
697 let dir = tempfile::tempdir().unwrap();
698 let layout = Layout::new(dir.path(), DEFAULT_PREFIX);
699 fs::create_dir_all(layout.projects_dir()).unwrap();
700 crate::ops::create(
701 &layout,
702 "alpha",
703 "keep this",
704 crate::ops::CreateOpts::default(),
705 )
706 .unwrap();
707 crate::ops::create(
708 &layout,
709 "alpha",
710 "secret tree",
711 crate::ops::CreateOpts {
712 tags: Some("noexport"),
713 ..Default::default()
714 },
715 )
716 .unwrap();
717 let text = render(&layout, &["alpha".into()], Format::Org, None).unwrap();
718 assert!(text.contains("keep this"), "{text}");
719 assert!(
720 !text.contains("secret tree"),
721 "a heading tagged noexport stayed in the Org mirror: {text}"
722 );
723 }
724}