Skip to main content

brief/
watch.rs

1//! `brief watch` — file/directory watcher that recompiles on change.
2//!
3//! Behavior (v0.3.7):
4//!   - .brf change       → recompile that file
5//!   - brief.toml change → recompile all files (default), with one refinement:
6//!                         if the only field that changed across the diff is
7//!                         a shortcode `template_html`/`template_llm` string,
8//!                         only files referencing those shortcodes recompile.
9//!   - 100ms debounce per path (notify-debouncer-mini).
10//!   - Whole-file recompile only. Block-level incremental is v0.4+.
11//!
12//! The watcher reuses the existing lex→parse→resolve→validate→emit pipeline.
13//! It writes outputs next to each source: `note.brf` → `note.html` /
14//! `note.txt` / `note.json`.
15
16use crate::config::{self, Config};
17use crate::diag::{Severity, render_all};
18use crate::emit::{html, llm};
19use crate::lexer;
20use crate::parser;
21use crate::shortcode::Registry;
22use crate::span::SourceMap;
23use crate::validate;
24
25use notify_debouncer_mini::{
26    DebounceEventResult, DebouncedEvent, new_debouncer, notify::RecursiveMode,
27};
28use std::collections::{BTreeSet, HashMap, HashSet};
29use std::io::Write;
30use std::path::{Path, PathBuf};
31use std::sync::mpsc::channel;
32use std::time::Duration;
33
34pub const DEBOUNCE_MS: u64 = 100;
35
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37pub enum Target {
38    Html,
39    Llm,
40    Json,
41}
42
43impl Target {
44    pub fn parse(s: &str) -> Option<Target> {
45        match s {
46            "html" => Some(Target::Html),
47            "llm" => Some(Target::Llm),
48            "json" => Some(Target::Json),
49            _ => None,
50        }
51    }
52
53    pub fn out_ext(self) -> &'static str {
54        match self {
55            Target::Html => "html",
56            Target::Llm => "txt",
57            Target::Json => "json",
58        }
59    }
60}
61
62#[derive(Clone, Debug, Default)]
63pub struct LlmOpts {
64    pub strip_emphasis: bool,
65    pub keep_table_rule: bool,
66    pub keep_asset_urls: bool,
67    pub keep_metadata: bool,
68}
69
70#[derive(Clone, Debug)]
71pub struct WatchOpts {
72    pub paths: Vec<PathBuf>,
73    pub target: Target,
74    pub config_path: PathBuf,
75    pub llm_opts: LlmOpts,
76    /// When true, suppress the clear-screen sequence between recompile runs.
77    pub no_clear: bool,
78}
79
80#[derive(Debug)]
81pub enum CompileOutcome {
82    Ok {
83        src: PathBuf,
84        dst: PathBuf,
85        diag_count: usize,
86    },
87    LexError {
88        src: PathBuf,
89    },
90    Errors {
91        src: PathBuf,
92        count: usize,
93    },
94    IoError {
95        src: PathBuf,
96        msg: String,
97    },
98}
99
100#[derive(Debug, PartialEq, Eq)]
101pub enum ConfigDelta {
102    /// Recompile every tracked file.
103    All,
104    /// Only template strings of these shortcodes differ; recompile files
105    /// that reference any of them.
106    Templates(BTreeSet<String>),
107    /// No effective change.
108    None,
109}
110
111pub struct Engine {
112    pub config_path: PathBuf,
113    pub config: Config,
114    pub registry: Registry,
115    pub target: Target,
116    pub llm_opts: LlmOpts,
117    pub files: BTreeSet<PathBuf>,
118    /// When true, suppress the clear-screen sequence between recompile runs.
119    pub no_clear: bool,
120    /// Per-file: set of shortcode names referenced in source. Built/refreshed
121    /// each time a file is compiled.
122    shortcode_use: HashMap<PathBuf, HashSet<String>>,
123}
124
125impl Engine {
126    pub fn load(opts: &WatchOpts) -> Result<Self, String> {
127        let config = if opts.config_path.exists() {
128            config::load(&opts.config_path).map_err(|e| format!("bad config: {e}"))?
129        } else {
130            Config::default()
131        };
132        let registry = config::registry_from(&config);
133        let files: BTreeSet<PathBuf> = discover_brf(&opts.paths).into_iter().collect();
134        Ok(Engine {
135            config_path: opts.config_path.clone(),
136            config,
137            registry,
138            target: opts.target,
139            llm_opts: opts.llm_opts.clone(),
140            files,
141            no_clear: opts.no_clear,
142            shortcode_use: HashMap::new(),
143        })
144    }
145
146    pub fn add_file(&mut self, p: PathBuf) {
147        self.files.insert(p);
148    }
149
150    pub fn compile_all<W: Write>(&mut self, log: &mut W) -> Vec<CompileOutcome> {
151        let files: Vec<PathBuf> = self.files.iter().cloned().collect();
152        files
153            .into_iter()
154            .map(|f| self.compile_one(&f, log))
155            .collect()
156    }
157
158    pub fn compile_one<W: Write>(&mut self, path: &Path, log: &mut W) -> CompileOutcome {
159        let raw = match std::fs::read_to_string(path) {
160            Ok(s) => s,
161            Err(e) => {
162                let _ = writeln!(log, "brief: cannot read {}: {}", path.display(), e);
163                return CompileOutcome::IoError {
164                    src: path.to_path_buf(),
165                    msg: e.to_string(),
166                };
167            }
168        };
169        let source = raw.strip_prefix('\u{feff}').unwrap_or(&raw).to_string();
170
171        // Refresh shortcode-usage cache before compile so a brief.toml-only
172        // template change can correctly filter to files using the shortcode.
173        self.shortcode_use
174            .insert(path.to_path_buf(), scan_shortcode_uses(&source));
175
176        let src = SourceMap::new(path.to_string_lossy(), source);
177        let opts = validate::ValidateOpts {
178            strict_heading_levels: self.config.compile.strict_heading_levels,
179        };
180
181        let tokens = match lexer::lex(&src) {
182            Ok(t) => t,
183            Err(d) => {
184                let _ = write!(log, "{}", render_all(&d, &src));
185                return CompileOutcome::LexError {
186                    src: path.to_path_buf(),
187                };
188            }
189        };
190        let (mut doc, mut diags) = parser::parse(tokens, &src);
191        let abs_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
192        let project_root = crate::project::discover_root(&abs_path);
193        let project_index = match &project_root {
194            Some(root) => {
195                let (idx, prepass_diags) = crate::project::build_index(root);
196                let mut has_err = false;
197                for fd in &prepass_diags {
198                    if fd.diagnostics.is_empty() {
199                        continue;
200                    }
201                    if fd.diagnostics.iter().any(|d| d.severity == Severity::Error) {
202                        has_err = true;
203                    }
204                    let _ = write!(log, "{}", render_all(&fd.diagnostics, &fd.source));
205                }
206                if has_err {
207                    // Pre-pass error in some sibling file. Compile of the
208                    // current file is aborted; the user sees the error and
209                    // a follow-up watch tick after the fix will clear it.
210                    return CompileOutcome::Errors {
211                        src: path.to_path_buf(),
212                        count: prepass_diags
213                            .iter()
214                            .map(|fd| {
215                                fd.diagnostics
216                                    .iter()
217                                    .filter(|d| d.severity == Severity::Error)
218                                    .count()
219                            })
220                            .sum(),
221                    };
222                }
223                Some(idx)
224            }
225            None => None,
226        };
227        let resolve_project = match (&project_root, &project_index) {
228            (Some(root), Some(idx)) => {
229                let rel = abs_path
230                    .strip_prefix(root)
231                    .map(|p| p.to_path_buf())
232                    .unwrap_or_else(|_| path.to_path_buf());
233                Some((idx, rel))
234            }
235            _ => None,
236        };
237        let project_arg =
238            resolve_project
239                .as_ref()
240                .map(|(idx, rel)| crate::resolve::ResolveProject {
241                    index: idx,
242                    current: rel.as_path(),
243                });
244        diags.extend(crate::resolve::resolve_with_project(
245            &mut doc,
246            &self.registry,
247            project_arg.as_ref(),
248        ));
249        diags.extend(validate::validate(&doc, &opts, &src));
250        let errors = diags
251            .iter()
252            .filter(|d| d.severity == Severity::Error)
253            .count();
254        if errors > 0 {
255            let _ = write!(log, "{}", render_all(&diags, &src));
256            return CompileOutcome::Errors {
257                src: path.to_path_buf(),
258                count: errors,
259            };
260        }
261        if !diags.is_empty() {
262            let _ = write!(log, "{}", render_all(&diags, &src));
263        }
264
265        let output = match self.target {
266            Target::Html => html::render(&doc, &self.registry),
267            Target::Llm => {
268                let lopts = llm::Opts {
269                    strip_emphasis: self.llm_opts.strip_emphasis,
270                    keep_table_rule: self.llm_opts.keep_table_rule,
271                    keep_asset_urls: self.llm_opts.keep_asset_urls,
272                    keep_metadata: self.llm_opts.keep_metadata,
273                    minify_code_blocks: self.config.compile.llm.minify_code_blocks,
274                    minify_languages: self.config.compile.llm.minify_languages.clone(),
275                    preserve_code_fences: self.config.compile.llm.preserve_code_fences,
276                };
277                let (out, warnings) = llm::render(&doc, &self.registry, &lopts);
278                for w in &warnings {
279                    let _ = writeln!(log, "brief: {}", w);
280                }
281                out
282            }
283            Target::Json => format!("{:#?}\n", doc),
284        };
285
286        let dst = output_path(path, self.target);
287        if let Err(e) = std::fs::write(&dst, &output) {
288            let _ = writeln!(log, "brief: cannot write {}: {}", dst.display(), e);
289            return CompileOutcome::IoError {
290                src: path.to_path_buf(),
291                msg: e.to_string(),
292            };
293        }
294        CompileOutcome::Ok {
295            src: path.to_path_buf(),
296            dst,
297            diag_count: diags.len(),
298        }
299    }
300
301    pub fn reload_config(&mut self) -> Result<ConfigDelta, String> {
302        let new_cfg = if self.config_path.exists() {
303            config::load(&self.config_path).map_err(|e| e.to_string())?
304        } else {
305            Config::default()
306        };
307        let delta = diff_config(&self.config, &new_cfg);
308        self.config = new_cfg;
309        self.registry = config::registry_from(&self.config);
310        Ok(delta)
311    }
312
313    pub fn files_using(&self, shortcodes: &BTreeSet<String>) -> Vec<PathBuf> {
314        self.files
315            .iter()
316            .filter(|f| {
317                self.shortcode_use
318                    .get(*f)
319                    .map(|uses| uses.iter().any(|u| shortcodes.contains(u)))
320                    .unwrap_or(false)
321            })
322            .cloned()
323            .collect()
324    }
325}
326
327pub fn output_path(src: &Path, target: Target) -> PathBuf {
328    let ext = target.out_ext();
329    let mut p = src.to_path_buf();
330    if p.extension().and_then(|s| s.to_str()) == Some("brf") {
331        p.set_extension(ext);
332    } else {
333        let mut name = p.file_name().unwrap_or_default().to_os_string();
334        name.push(".");
335        name.push(ext);
336        p.set_file_name(name);
337    }
338    p
339}
340
341/// Scan a Brief source for shortcode invocations. Returns the set of
342/// names referenced. Conservative: catches `@name(...)`, `@name[...]`, and
343/// bare `@name` followed by whitespace. Used only to decide which files
344/// need recompilation when a template changes — false positives just cause
345/// a redundant recompile, never a missed one.
346pub fn scan_shortcode_uses(source: &str) -> HashSet<String> {
347    let mut out = HashSet::new();
348    let bytes = source.as_bytes();
349    let mut i = 0usize;
350    while i < bytes.len() {
351        if bytes[i] != b'@' {
352            i += 1;
353            continue;
354        }
355        // Escaped `\@` is not a shortcode start.
356        if i > 0 && bytes[i - 1] == b'\\' {
357            i += 1;
358            continue;
359        }
360        let start = i + 1;
361        let mut j = start;
362        while j < bytes.len() {
363            let c = bytes[j];
364            if c.is_ascii_alphanumeric() || c == b'_' || c == b'-' {
365                j += 1;
366            } else {
367                break;
368            }
369        }
370        if j > start {
371            // Heuristic: must be followed by `(`, `[`, whitespace, or EOF.
372            // Filters out things like email addresses (`a@b.com` — no `@`
373            // prefix at start; conservative anyway since bytes[i] == '@'
374            // and previous char is `a`, but `a` is alphanumeric and we'd
375            // otherwise add `b` — so check: prior char must not be ident).
376            let prev_is_ident = i > 0
377                && (bytes[i - 1].is_ascii_alphanumeric()
378                    || bytes[i - 1] == b'_'
379                    || bytes[i - 1] == b'-');
380            let next = bytes.get(j).copied().unwrap_or(b' ');
381            let next_ok =
382                matches!(next, b'(' | b'[' | b' ' | b'\t' | b'\n' | b'\r') || j == bytes.len();
383            if !prev_is_ident && next_ok {
384                if let Ok(name) = std::str::from_utf8(&bytes[start..j]) {
385                    out.insert(name.to_string());
386                }
387            }
388        }
389        i = j.max(i + 1);
390    }
391    out
392}
393
394pub fn diff_config(old: &Config, new: &Config) -> ConfigDelta {
395    if old.project != new.project || old.compile != new.compile || old.hooks != new.hooks {
396        return ConfigDelta::All;
397    }
398
399    let old_keys: BTreeSet<&String> = old.shortcodes.keys().collect();
400    let new_keys: BTreeSet<&String> = new.shortcodes.keys().collect();
401    if old_keys != new_keys {
402        // Adding/removing a shortcode is a structural change — could affect
403        // any file (e.g. a previously unknown shortcode is now resolved).
404        return ConfigDelta::All;
405    }
406
407    let mut template_changed: BTreeSet<String> = BTreeSet::new();
408    for k in &new_keys {
409        let o = &old.shortcodes[*k];
410        let n = &new.shortcodes[*k];
411        if o.kind != n.kind || o.arguments != n.arguments {
412            return ConfigDelta::All;
413        }
414        if o.template_html != n.template_html || o.template_llm != n.template_llm {
415            template_changed.insert((*k).clone());
416        }
417    }
418
419    if template_changed.is_empty() {
420        ConfigDelta::None
421    } else {
422        ConfigDelta::Templates(template_changed)
423    }
424}
425
426fn discover_brf(paths: &[PathBuf]) -> Vec<PathBuf> {
427    let mut out: Vec<PathBuf> = Vec::new();
428    for p in paths {
429        if p.is_file() {
430            if p.extension().and_then(|s| s.to_str()) == Some("brf") {
431                out.push(canonicalize_or_clone(p));
432            }
433        } else if p.is_dir() {
434            walk_dir(p, &mut out);
435        }
436    }
437    out.sort();
438    out.dedup();
439    out
440}
441
442fn walk_dir(dir: &Path, out: &mut Vec<PathBuf>) {
443    let entries = match std::fs::read_dir(dir) {
444        Ok(e) => e,
445        Err(_) => return,
446    };
447    for entry in entries.flatten() {
448        let path = entry.path();
449        let ft = match entry.file_type() {
450            Ok(t) => t,
451            Err(_) => continue,
452        };
453        if ft.is_dir() {
454            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
455            // Skip hidden/build dirs to avoid recursing into `.git`, `target`, etc.
456            if name.starts_with('.') || name == "target" || name == "node_modules" {
457                continue;
458            }
459            walk_dir(&path, out);
460        } else if ft.is_file() && path.extension().and_then(|s| s.to_str()) == Some("brf") {
461            out.push(canonicalize_or_clone(&path));
462        }
463    }
464}
465
466fn canonicalize_or_clone(p: &Path) -> PathBuf {
467    if let Ok(canon) = p.canonicalize() {
468        return canon;
469    }
470    // Path may not exist (e.g. just deleted). Canonicalizing the parent
471    // and re-joining the filename gives us a stable identity for lookup
472    // against `engine.files` (whose entries were canonicalized at
473    // discovery time, before any deletion).
474    if let (Some(parent), Some(name)) = (p.parent(), p.file_name()) {
475        if !parent.as_os_str().is_empty() {
476            if let Ok(parent_canon) = parent.canonicalize() {
477                return parent_canon.join(name);
478            }
479        }
480    }
481    p.to_path_buf()
482}
483
484pub fn run(opts: WatchOpts) -> Result<(), String> {
485    let mut log = std::io::stderr();
486    let mut engine = Engine::load(&opts)?;
487
488    if engine.files.is_empty() {
489        let _ = writeln!(log, "brief: no .brf files found in given paths");
490    }
491
492    // Initial compile pass.
493    let outcomes = engine.compile_all(&mut log);
494    print_outcomes(&outcomes, &mut log);
495
496    let (tx, rx) = channel();
497    let mut debouncer = new_debouncer(
498        Duration::from_millis(DEBOUNCE_MS),
499        move |res: DebounceEventResult| {
500            let _ = tx.send(res);
501        },
502    )
503    .map_err(|e| format!("watcher init failed: {e}"))?;
504
505    // Watch each input path. For directories, recursively. For files, watch
506    // the parent (some platforms don't report file-only watches reliably).
507    let mut watched: HashSet<PathBuf> = HashSet::new();
508    for p in &opts.paths {
509        let canon = canonicalize_or_clone(p);
510        let (target, mode) = if canon.is_file() {
511            (
512                canon
513                    .parent()
514                    .map(|pp| pp.to_path_buf())
515                    .unwrap_or_else(|| PathBuf::from(".")),
516                RecursiveMode::NonRecursive,
517            )
518        } else {
519            (canon.clone(), RecursiveMode::Recursive)
520        };
521        if watched.insert(target.clone()) {
522            debouncer
523                .watcher()
524                .watch(&target, mode)
525                .map_err(|e| format!("watch {}: {e}", target.display()))?;
526        }
527    }
528
529    // Always also watch the config file's containing directory.
530    let cfg_dir = engine
531        .config_path
532        .parent()
533        .map(|p| {
534            if p.as_os_str().is_empty() {
535                std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
536            } else {
537                p.to_path_buf()
538            }
539        })
540        .unwrap_or_else(|| PathBuf::from("."));
541    let cfg_dir = canonicalize_or_clone(&cfg_dir);
542    if watched.insert(cfg_dir.clone()) {
543        debouncer
544            .watcher()
545            .watch(&cfg_dir, RecursiveMode::NonRecursive)
546            .map_err(|e| format!("watch {}: {e}", cfg_dir.display()))?;
547    }
548
549    let _ = writeln!(
550        log,
551        "brief: watching {} file{} (debounce {}ms; Ctrl-C to exit)",
552        engine.files.len(),
553        if engine.files.len() == 1 { "" } else { "s" },
554        DEBOUNCE_MS
555    );
556
557    while let Ok(res) = rx.recv() {
558        match res {
559            Ok(events) => handle_events(events, &mut engine, &mut log),
560            Err(e) => {
561                let _ = writeln!(log, "brief: watcher error: {}", e);
562            }
563        }
564    }
565    Ok(())
566}
567
568fn handle_events<W: Write>(events: Vec<DebouncedEvent>, engine: &mut Engine, log: &mut W) {
569    let paths: Vec<PathBuf> = events.into_iter().map(|e| e.path).collect();
570    handle_change_paths(&paths, engine, log);
571}
572
573/// Dispatch a list of changed paths through the engine. Public for tests
574/// (so we don't have to drive the FS watcher to exercise the logic).
575pub fn handle_change_paths<W: Write>(paths: &[PathBuf], engine: &mut Engine, log: &mut W) {
576    // Clear screen between runs per spec §4.7, unless suppressed.
577    if !engine.no_clear {
578        let _ = write!(log, "\x1b[2J\x1b[H");
579    }
580
581    let cfg_canon = canonicalize_or_clone(&engine.config_path);
582    let mut config_changed = false;
583    let mut brf_changes: BTreeSet<PathBuf> = BTreeSet::new();
584
585    for path in paths {
586        let p = canonicalize_or_clone(path);
587        if p == cfg_canon {
588            config_changed = true;
589            continue;
590        }
591        if p.extension().and_then(|s| s.to_str()) == Some("brf") {
592            // New file? Track it. (Discovery is one-shot; this lets a new
593            // file dropped into the watched dir start being compiled.)
594            if !engine.files.contains(&p) && p.exists() {
595                engine.add_file(p.clone());
596            }
597            if engine.files.contains(&p) {
598                brf_changes.insert(p);
599            }
600        }
601    }
602
603    if config_changed {
604        match engine.reload_config() {
605            Ok(ConfigDelta::All) => {
606                let _ = writeln!(log, "brief: config changed → recompiling all");
607                let outcomes = engine.compile_all(log);
608                print_outcomes(&outcomes, log);
609                return; // covers any concurrent .brf changes
610            }
611            Ok(ConfigDelta::Templates(names)) => {
612                let listed: Vec<String> = names.iter().cloned().collect();
613                let _ = writeln!(
614                    log,
615                    "brief: template change ({}) → recompiling files using {}",
616                    listed.join(", "),
617                    listed.join(", ")
618                );
619                let files = engine.files_using(&names);
620                if files.is_empty() {
621                    let _ = writeln!(
622                        log,
623                        "brief: (no tracked file references {})",
624                        listed.join(", ")
625                    );
626                }
627                for f in &files {
628                    let outcome = engine.compile_one(f, log);
629                    print_outcomes(std::slice::from_ref(&outcome), log);
630                }
631                // Fall through to also handle direct .brf changes.
632            }
633            Ok(ConfigDelta::None) => { /* nothing material in config */ }
634            Err(e) => {
635                let _ = writeln!(log, "brief: config reload failed: {}", e);
636            }
637        }
638    }
639
640    for f in brf_changes {
641        if !f.exists() {
642            engine.files.remove(&f);
643            engine.shortcode_use.remove(&f);
644            let _ = writeln!(log, "brief: {} removed (no longer tracked)", f.display());
645            continue;
646        }
647        let outcome = engine.compile_one(&f, log);
648        print_outcomes(std::slice::from_ref(&outcome), log);
649    }
650}
651
652fn print_outcomes<W: Write>(outcomes: &[CompileOutcome], log: &mut W) {
653    for o in outcomes {
654        match o {
655            CompileOutcome::Ok {
656                src,
657                dst,
658                diag_count,
659            } => {
660                let suffix = if *diag_count > 0 {
661                    format!(
662                        " ({} note{})",
663                        diag_count,
664                        if *diag_count == 1 { "" } else { "s" }
665                    )
666                } else {
667                    String::new()
668                };
669                let _ = writeln!(
670                    log,
671                    "brief: {} → {}{}",
672                    src.display(),
673                    dst.display(),
674                    suffix
675                );
676            }
677            CompileOutcome::LexError { src } => {
678                let _ = writeln!(log, "brief: {} → FAILED (lex error)", src.display());
679            }
680            CompileOutcome::Errors { src, count } => {
681                let _ = writeln!(
682                    log,
683                    "brief: {} → FAILED ({} error{})",
684                    src.display(),
685                    count,
686                    if *count == 1 { "" } else { "s" }
687                );
688            }
689            CompileOutcome::IoError { src, msg } => {
690                let _ = writeln!(log, "brief: {} → FAILED: {}", src.display(), msg);
691            }
692        }
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use super::*;
699    use crate::shortcode::{ArgSpec, ArgType, ShortKindOpt, Shortcode};
700
701    fn cfg_with_shortcode(name: &str, tpl_html: Option<&str>, tpl_llm: Option<&str>) -> Config {
702        let mut c = Config::default();
703        c.shortcodes.insert(
704            name.into(),
705            Shortcode {
706                kind: ShortKindOpt::Inline,
707                arguments: Default::default(),
708                template_html: tpl_html.map(str::to_string),
709                template_llm: tpl_llm.map(str::to_string),
710            },
711        );
712        c
713    }
714
715    #[test]
716    fn diff_config_no_change() {
717        let a = Config::default();
718        let b = Config::default();
719        assert_eq!(diff_config(&a, &b), ConfigDelta::None);
720    }
721
722    #[test]
723    fn diff_config_template_only_change() {
724        let a = cfg_with_shortcode("note", Some("<div>{{content}}</div>"), None);
725        let b = cfg_with_shortcode("note", Some("<aside>{{content}}</aside>"), None);
726        let mut expected = BTreeSet::new();
727        expected.insert("note".to_string());
728        assert_eq!(diff_config(&a, &b), ConfigDelta::Templates(expected));
729    }
730
731    #[test]
732    fn diff_config_kind_change_is_structural() {
733        let a = cfg_with_shortcode("note", Some("x"), None);
734        let mut b = cfg_with_shortcode("note", Some("x"), None);
735        b.shortcodes.get_mut("note").unwrap().kind = ShortKindOpt::Block;
736        assert_eq!(diff_config(&a, &b), ConfigDelta::All);
737    }
738
739    #[test]
740    fn diff_config_arguments_change_is_structural() {
741        let mut a = Config::default();
742        a.shortcodes.insert(
743            "note".into(),
744            Shortcode {
745                kind: ShortKindOpt::Inline,
746                arguments: Default::default(),
747                template_html: Some("x".into()),
748                template_llm: None,
749            },
750        );
751        let mut b = a.clone();
752        b.shortcodes.get_mut("note").unwrap().arguments.insert(
753            "kind".into(),
754            ArgSpec {
755                ty: ArgType::String,
756                required: false,
757                position: None,
758                oneof: None,
759            },
760        );
761        assert_eq!(diff_config(&a, &b), ConfigDelta::All);
762    }
763
764    #[test]
765    fn diff_config_added_shortcode_is_structural() {
766        let a = Config::default();
767        let b = cfg_with_shortcode("note", Some("x"), None);
768        assert_eq!(diff_config(&a, &b), ConfigDelta::All);
769    }
770
771    #[test]
772    fn diff_config_compile_change_is_structural() {
773        let mut a = Config::default();
774        let mut b = Config::default();
775        b.compile.strict_heading_levels = !a.compile.strict_heading_levels;
776        assert_eq!(diff_config(&a, &b), ConfigDelta::All);
777        a.compile.strict_heading_levels = b.compile.strict_heading_levels;
778        assert_eq!(diff_config(&a, &b), ConfigDelta::None);
779    }
780
781    #[test]
782    fn scan_picks_up_block_and_inline_shortcodes() {
783        let s = "# Title\n\n@note(kind: tip)\nbody\n@end\n\nSome @link(\"u\") text.\n";
784        let uses = scan_shortcode_uses(s);
785        assert!(uses.contains("note"), "uses={:?}", uses);
786        assert!(uses.contains("link"), "uses={:?}", uses);
787        assert!(uses.contains("end"), "uses={:?}", uses); // benign false positive
788    }
789
790    #[test]
791    fn scan_ignores_email_addresses() {
792        let s = "Email me at foo@example.com please.\n";
793        let uses = scan_shortcode_uses(s);
794        // `@example` should not be picked up because previous char is `o`,
795        // an identifier char.
796        assert!(!uses.contains("example"), "uses={:?}", uses);
797    }
798
799    #[test]
800    fn scan_ignores_escaped_at() {
801        let s = "literal \\@notashort here\n";
802        let uses = scan_shortcode_uses(s);
803        assert!(!uses.contains("notashort"), "uses={:?}", uses);
804    }
805
806    #[test]
807    fn output_path_replaces_brf_extension() {
808        let p = output_path(Path::new("/tmp/foo/note.brf"), Target::Html);
809        assert_eq!(p, PathBuf::from("/tmp/foo/note.html"));
810        let p = output_path(Path::new("/tmp/foo/note.brf"), Target::Llm);
811        assert_eq!(p, PathBuf::from("/tmp/foo/note.txt"));
812        let p = output_path(Path::new("/tmp/foo/note.brf"), Target::Json);
813        assert_eq!(p, PathBuf::from("/tmp/foo/note.json"));
814    }
815
816    #[test]
817    fn output_path_no_brf_extension_appends() {
818        let p = output_path(Path::new("/tmp/foo/note"), Target::Html);
819        assert_eq!(p, PathBuf::from("/tmp/foo/note.html"));
820    }
821
822    #[test]
823    fn engine_compile_writes_html_output() {
824        let dir = std::env::temp_dir().join("brief-watch-engine-html");
825        let _ = std::fs::remove_dir_all(&dir);
826        std::fs::create_dir_all(&dir).unwrap();
827        let f = dir.join("doc.brf");
828        std::fs::write(&f, "# Hello\n").unwrap();
829        let opts = WatchOpts {
830            paths: vec![dir.clone()],
831            target: Target::Html,
832            config_path: dir.join("brief.toml"),
833            llm_opts: LlmOpts::default(),
834            no_clear: true,
835        };
836        let mut engine = Engine::load(&opts).unwrap();
837        let mut sink: Vec<u8> = Vec::new();
838        let outcomes = engine.compile_all(&mut sink);
839        assert_eq!(outcomes.len(), 1);
840        match &outcomes[0] {
841            CompileOutcome::Ok { dst, .. } => {
842                let html = std::fs::read_to_string(dst).unwrap();
843                assert!(html.contains("<h1"), "html={}", html);
844                assert!(html.contains("Hello"), "html={}", html);
845            }
846            other => panic!("expected Ok, got {:?}", other),
847        }
848    }
849
850    #[test]
851    fn engine_files_using_shortcode_filters_correctly() {
852        let dir = std::env::temp_dir().join("brief-watch-files-using");
853        let _ = std::fs::remove_dir_all(&dir);
854        std::fs::create_dir_all(&dir).unwrap();
855        let a = dir.join("uses_note.brf");
856        let b = dir.join("plain.brf");
857        std::fs::write(&a, "@note(kind: tip)\nhi\n@end\n").unwrap();
858        std::fs::write(&b, "# plain\n").unwrap();
859
860        // Config with a `note` block shortcode so resolution succeeds.
861        let cfg_path = dir.join("brief.toml");
862        std::fs::write(
863            &cfg_path,
864            r#"
865[shortcodes.note]
866kind = "block"
867template_html = "<aside>{{content}}</aside>"
868"#,
869        )
870        .unwrap();
871
872        let opts = WatchOpts {
873            paths: vec![dir.clone()],
874            target: Target::Html,
875            config_path: cfg_path,
876            llm_opts: LlmOpts::default(),
877            no_clear: false,
878        };
879        let mut engine = Engine::load(&opts).unwrap();
880        let mut sink: Vec<u8> = Vec::new();
881        let _ = engine.compile_all(&mut sink);
882
883        let mut names = BTreeSet::new();
884        names.insert("note".to_string());
885        let users = engine.files_using(&names);
886        assert_eq!(users.len(), 1);
887        assert!(users[0].ends_with("uses_note.brf"), "users={:?}", users);
888    }
889
890    #[test]
891    fn clear_screen_emitted_before_subsequent_compile() {
892        let dir = std::env::temp_dir().join("brief-watch-clear-emitted");
893        let _ = std::fs::remove_dir_all(&dir);
894        std::fs::create_dir_all(&dir).unwrap();
895        let f = dir.join("doc.brf");
896        std::fs::write(&f, "# Hello\n").unwrap();
897
898        let opts = WatchOpts {
899            paths: vec![dir.clone()],
900            target: Target::Html,
901            config_path: dir.join("brief.toml"),
902            llm_opts: LlmOpts::default(),
903            no_clear: false,
904        };
905        let mut engine = Engine::load(&opts).unwrap();
906
907        // Simulate initial compile (no clear expected here).
908        let mut initial_log: Vec<u8> = Vec::new();
909        let _ = engine.compile_all(&mut initial_log);
910        assert!(
911            !String::from_utf8_lossy(&initial_log).contains("\x1b[2J"),
912            "clear should NOT appear on initial compile"
913        );
914
915        // Simulate a subsequent file-change event.
916        let mut log: Vec<u8> = Vec::new();
917        handle_change_paths(&[f.clone()], &mut engine, &mut log);
918        let out = String::from_utf8_lossy(&log);
919        assert!(
920            out.contains("\x1b[2J\x1b[H"),
921            "clear sequence should appear in subsequent compile log; got: {:?}",
922            out
923        );
924    }
925
926    #[test]
927    fn clear_screen_suppressed_by_no_clear() {
928        let dir = std::env::temp_dir().join("brief-watch-clear-suppressed");
929        let _ = std::fs::remove_dir_all(&dir);
930        std::fs::create_dir_all(&dir).unwrap();
931        let f = dir.join("doc.brf");
932        std::fs::write(&f, "# Hello\n").unwrap();
933
934        let opts = WatchOpts {
935            paths: vec![dir.clone()],
936            target: Target::Html,
937            config_path: dir.join("brief.toml"),
938            llm_opts: LlmOpts::default(),
939            no_clear: true,
940        };
941        let mut engine = Engine::load(&opts).unwrap();
942        let mut initial_log: Vec<u8> = Vec::new();
943        let _ = engine.compile_all(&mut initial_log);
944
945        let mut log: Vec<u8> = Vec::new();
946        handle_change_paths(&[f.clone()], &mut engine, &mut log);
947        let out = String::from_utf8_lossy(&log);
948        assert!(
949            !out.contains("\x1b[2J"),
950            "clear sequence should be suppressed when no_clear=true; got: {:?}",
951            out
952        );
953    }
954
955    #[test]
956    fn clear_screen_not_emitted_on_initial_compile() {
957        let dir = std::env::temp_dir().join("brief-watch-clear-initial");
958        let _ = std::fs::remove_dir_all(&dir);
959        std::fs::create_dir_all(&dir).unwrap();
960        let f = dir.join("doc.brf");
961        std::fs::write(&f, "# Hello\n").unwrap();
962
963        let opts = WatchOpts {
964            paths: vec![dir.clone()],
965            target: Target::Html,
966            config_path: dir.join("brief.toml"),
967            llm_opts: LlmOpts::default(),
968            no_clear: false,
969        };
970        let mut engine = Engine::load(&opts).unwrap();
971        let mut log: Vec<u8> = Vec::new();
972        // This is the initial compile path — no clear-screen should appear.
973        let _ = engine.compile_all(&mut log);
974        let out = String::from_utf8_lossy(&log);
975        assert!(
976            !out.contains("\x1b[2J"),
977            "clear sequence should NOT appear on initial compile; got: {:?}",
978            out
979        );
980        // Also verify it did compile something.
981        let html_path = f.with_extension("html");
982        assert!(
983            html_path.exists(),
984            "html output should exist after initial compile"
985        );
986    }
987
988    #[test]
989    fn watch_engine_runs_project_pre_pass_for_refs() {
990        use tempfile::TempDir;
991
992        let td = TempDir::new().unwrap();
993        let root = td.path();
994
995        // Without brief.toml: @ref produces B0604.
996        std::fs::write(root.join("a.brf"), "# A {#x}\n").unwrap();
997        std::fs::write(root.join("b.brf"), "@ref[a.brf#x](X)\n").unwrap();
998
999        let opts = WatchOpts {
1000            paths: vec![root.to_path_buf()],
1001            target: Target::Html,
1002            config_path: root.join("brief.toml"),
1003            llm_opts: LlmOpts::default(),
1004            no_clear: true,
1005        };
1006        let mut engine = Engine::load(&opts).unwrap();
1007        let mut log = Vec::new();
1008        let outcome = engine.compile_one(&root.join("b.brf"), &mut log);
1009        let log_str = String::from_utf8(log).unwrap();
1010        assert!(
1011            matches!(outcome, CompileOutcome::Errors { .. }),
1012            "outcome: {:?}, log: {}",
1013            outcome,
1014            log_str,
1015        );
1016        assert!(log_str.contains("B0604"), "log: {}", log_str);
1017
1018        // With brief.toml: clean compile.
1019        std::fs::write(root.join("brief.toml"), "").unwrap();
1020        let mut log = Vec::new();
1021        let outcome = engine.compile_one(&root.join("b.brf"), &mut log);
1022        let log_str = String::from_utf8(log).unwrap();
1023        assert!(
1024            matches!(outcome, CompileOutcome::Ok { .. }),
1025            "outcome: {:?}, log: {}",
1026            outcome,
1027            log_str,
1028        );
1029    }
1030}