1use 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 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 All,
104 Templates(BTreeSet<String>),
107 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 pub no_clear: bool,
120 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 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 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
341pub 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 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 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 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 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 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 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 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 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
573pub fn handle_change_paths<W: Write>(paths: &[PathBuf], engine: &mut Engine, log: &mut W) {
576 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 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; }
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 }
633 Ok(ConfigDelta::None) => { }
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); }
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 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 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 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 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 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 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 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 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}