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            .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
334/// Issues render at level two, so a body heading has to sit at level three or
335/// deeper. Without the shift, a body that opens with `** Scope` becomes a
336/// sibling of the issues and the projection's outline is wrong.
337const ISSUE_LEVEL: usize = 2;
338
339fn render_org_issue(out: &mut String, h: &IssueHeading) -> Result<()> {
340    // The projection is an Org file someone opens in Emacs, so it carries the
341    // dates and tags the same way the tracker does: on the planning line and
342    // the heading, where Org's agenda and tag search read them.
343    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
378/// `:KEY:` padded to the column the tracker's own writer uses.
379fn 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
385/// Push every heading in a body below the issue that owns it, preserving the
386/// relative nesting the author wrote.
387fn 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
409/// The number of leading stars, when the line is an org heading.
410fn 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
449/// Collapse blank runs and stop after [`BODY_LINES`], marking the cut.
450fn 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        // Tags ride the heading, which is where Org's tag search reads them.
516        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        // Level-one project names are Org sections, not issues. Level-two
538        // issue headings stay in that section's body. A reparse must not
539        // fail the file for a missing :ID: on the project heading.
540        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        // A body written with level-two headings would otherwise render as a
578        // sibling of the issues, so the outline would claim Scope is an issue.
579        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        // The comment wrappers each format uses must both parse back.
654        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}