1pub mod check;
16mod parse;
17#[cfg(test)]
18mod tests;
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::sync::{Arc, Mutex};
23
24pub use check::{OtherFiles, Violation, evaluate_change, evaluate_change_with};
25pub use parse::{id_from_slug, parse_decision, scaffold};
26
27pub const DECISIONS_DIR: &str = ".leanctx/decisions";
29pub const SUFFIX_HEADER: &str = "--- Decisions in scope ---";
31const MAX_SUFFIX_DECISIONS: usize = 8;
33const MAX_SUFFIX_LINE_CHARS: usize = 200;
35const MAX_DECISION_FILES: usize = 512;
37const MAX_DECISION_BYTES: u64 = 64 * 1024;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub enum Status {
42 Active,
43 Superseded,
44 Revoked,
45}
46
47impl Status {
48 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::Active => "active",
51 Self::Superseded => "superseded",
52 Self::Revoked => "revoked",
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CheckKind {
59 ForbidPath,
60 ForbidRegex,
61 RequireRegex,
62}
63
64impl CheckKind {
65 pub fn as_str(self) -> &'static str {
66 match self {
67 Self::ForbidPath => "forbid_path",
68 Self::ForbidRegex => "forbid_regex",
69 Self::RequireRegex => "require_regex",
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
76pub struct Check {
77 pub kind: CheckKind,
78 pub paths: Vec<String>,
79 globs: Vec<glob::Pattern>,
80 pub pattern: Option<String>,
81 regex: Option<regex::Regex>,
82 pub message: Option<String>,
83}
84
85#[derive(Debug, Clone)]
87pub struct Decision {
88 pub id: String,
89 pub title: String,
90 pub statement: String,
91 pub rationale: String,
92 pub status: Status,
93 pub supersedes: Vec<String>,
94 pub decided_by: String,
95 pub decided_at: String,
96 pub source_kind: Option<String>,
97 pub source_ref: Option<String>,
98 pub repos: Vec<String>,
99 pub paths: Vec<String>,
100 path_globs: Vec<glob::Pattern>,
101 pub symbols: Vec<String>,
102 pub checks: Vec<Check>,
103 pub file: String,
105}
106
107#[derive(Debug, Clone, Default)]
109pub struct DecisionSet {
110 pub decisions: Vec<Decision>,
111 pub errors: Vec<String>,
113 repo_names: Vec<String>,
115 root: Option<PathBuf>,
117}
118
119fn match_options() -> glob::MatchOptions {
120 glob::MatchOptions {
121 case_sensitive: true,
122 require_literal_separator: true,
123 require_literal_leading_dot: false,
124 }
125}
126
127impl Check {
128 pub fn covers(&self, decision: &Decision, rel_path: &str) -> bool {
130 if self.globs.is_empty() {
131 decision.covers_path(rel_path)
132 } else {
133 let opts = match_options();
134 self.globs.iter().any(|g| g.matches_with(rel_path, opts))
135 }
136 }
137
138 pub fn regex(&self) -> Option<®ex::Regex> {
139 self.regex.as_ref()
140 }
141}
142
143impl Decision {
144 pub fn is_repo_wide(&self) -> bool {
146 self.paths.is_empty() && self.symbols.is_empty()
147 }
148
149 pub fn covers_path(&self, rel_path: &str) -> bool {
152 if self.is_repo_wide() {
153 return true;
154 }
155 let opts = match_options();
156 self.path_globs
157 .iter()
158 .any(|g| g.matches_with(rel_path, opts))
159 }
160
161 pub fn covers_symbol(&self, symbol: &str) -> bool {
163 self.symbols.iter().any(|s| symbol_matches(s, symbol))
164 }
165
166 pub fn one_line(&self) -> String {
168 let line = self
169 .statement
170 .lines()
171 .map(str::trim)
172 .find(|l| !l.is_empty())
173 .unwrap_or_default();
174 truncate_chars(line, MAX_SUFFIX_LINE_CHARS)
175 }
176}
177
178fn symbol_matches(a: &str, b: &str) -> bool {
181 let (a, b) = (a.trim(), b.trim());
182 if a.is_empty() || b.is_empty() {
183 return false;
184 }
185 if a == b {
186 return true;
187 }
188 let last = |s: &str| s.rsplit("::").next().unwrap_or(s).to_string();
189 (!a.contains("::") || !b.contains("::")) && last(a) == last(b)
190}
191
192fn truncate_chars(s: &str, max: usize) -> String {
193 if s.chars().count() <= max {
194 return s.to_string();
195 }
196 let cut: String = s.chars().take(max.saturating_sub(1)).collect();
197 format!("{cut}…")
198}
199
200impl DecisionSet {
201 pub fn active(&self) -> impl Iterator<Item = &Decision> {
204 let superseded: Vec<&str> = self
205 .decisions
206 .iter()
207 .filter(|d| d.status == Status::Active)
208 .flat_map(|d| d.supersedes.iter().map(String::as_str))
209 .collect();
210 self.decisions
211 .iter()
212 .filter(move |d| d.status == Status::Active && !superseded.contains(&d.id.as_str()))
213 }
214
215 pub fn effective_status(&self, d: &Decision) -> Status {
218 if d.status == Status::Active && self.active().all(|a| a.id != d.id) {
219 Status::Superseded
220 } else {
221 d.status
222 }
223 }
224
225 pub fn get(&self, id: &str) -> Option<&Decision> {
226 self.decisions.iter().find(|d| d.id == id)
227 }
228
229 pub fn repo_matches(&self, d: &Decision) -> bool {
231 d.repos.is_empty()
232 || d.repos
233 .iter()
234 .any(|r| self.repo_names.iter().any(|n| n == &r.to_lowercase()))
235 }
236
237 pub fn for_path(&self, rel_path: &str) -> Vec<&Decision> {
239 self.active()
240 .filter(|d| self.repo_matches(d) && d.covers_path(rel_path))
241 .collect()
242 }
243
244 pub fn for_symbol(&self, symbol: &str) -> Vec<&Decision> {
246 self.active()
247 .filter(|d| self.repo_matches(d) && d.covers_symbol(symbol))
248 .collect()
249 }
250
251 pub fn is_empty(&self) -> bool {
252 self.decisions.is_empty() && self.errors.is_empty()
253 }
254}
255
256type Signature = Vec<(String, u64, u128)>;
259
260static CACHE: Mutex<Option<HashMap<PathBuf, (Signature, Arc<DecisionSet>)>>> = Mutex::new(None);
261
262fn record_files(dir: &Path) -> Vec<PathBuf> {
263 let Ok(entries) = std::fs::read_dir(dir) else {
264 return Vec::new();
265 };
266 let mut files: Vec<PathBuf> = entries
267 .filter_map(Result::ok)
268 .map(|e| e.path())
269 .filter(|p| p.extension().is_some_and(|e| e == "toml") && p.is_file())
270 .collect();
271 files.sort();
272 files
273}
274
275fn signature(files: &[PathBuf]) -> Signature {
276 files
277 .iter()
278 .map(|p| {
279 let meta = std::fs::metadata(p).ok();
280 let len = meta.as_ref().map_or(0, std::fs::Metadata::len);
281 let mtime = meta
282 .and_then(|m| m.modified().ok())
283 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
284 .map_or(0, |d| d.as_nanos());
285 (p.to_string_lossy().into_owned(), len, mtime)
286 })
287 .collect()
288}
289
290pub fn load(root: &Path) -> Arc<DecisionSet> {
293 let dir = root.join(DECISIONS_DIR);
294 let files = record_files(&dir);
295 if files.is_empty() {
296 return Arc::new(DecisionSet::default());
297 }
298 let sig = signature(&files);
299 {
300 let guard = CACHE
301 .lock()
302 .unwrap_or_else(std::sync::PoisonError::into_inner);
303 if let Some((cached_sig, set)) = guard.as_ref().and_then(|c| c.get(root))
304 && *cached_sig == sig
305 {
306 return Arc::clone(set);
307 }
308 }
309 let set = Arc::new(load_uncached(root, &files));
310 let mut guard = CACHE
311 .lock()
312 .unwrap_or_else(std::sync::PoisonError::into_inner);
313 guard
314 .get_or_insert_with(HashMap::new)
315 .insert(root.to_path_buf(), (sig, Arc::clone(&set)));
316 set
317}
318
319fn rel_display(root: &Path, file: &Path) -> String {
320 file.strip_prefix(root)
321 .unwrap_or(file)
322 .to_string_lossy()
323 .replace('\\', "/")
324}
325
326fn load_uncached(root: &Path, files: &[PathBuf]) -> DecisionSet {
327 let records: Vec<(String, Result<String, String>)> = files
328 .iter()
329 .take(MAX_DECISION_FILES + 1)
330 .map(|file| {
331 let rel = rel_display(root, file);
332 let text = match std::fs::metadata(file) {
333 Ok(m) if m.len() > MAX_DECISION_BYTES => Err(format!(
334 "{} bytes, more than the limit of {MAX_DECISION_BYTES}",
335 m.len()
336 )),
337 _ => std::fs::read_to_string(file).map_err(|e| format!("unreadable: {e}")),
338 };
339 (rel, text)
340 })
341 .collect();
342 let mut set = from_records(root, records);
343 set.root = Some(root.to_path_buf());
344 set
345}
346
347pub fn from_records(
352 repo_root: &Path,
353 records: Vec<(String, Result<String, String>)>,
354) -> DecisionSet {
355 let mut set = DecisionSet {
356 repo_names: repo_names(repo_root),
357 ..DecisionSet::default()
358 };
359 if records.len() > MAX_DECISION_FILES {
360 set.errors.push(format!(
361 "{DECISIONS_DIR}: more than the limit of {MAX_DECISION_FILES} record files; none loaded"
362 ));
363 return set;
364 }
365 for (rel, text) in records {
366 let stem = Path::new(&rel)
367 .file_stem()
368 .map(|s| s.to_string_lossy().into_owned())
369 .unwrap_or_default();
370 let text = match text {
371 Ok(t) if t.len() as u64 > MAX_DECISION_BYTES => {
372 set.errors.push(format!(
373 "{rel}: {} bytes, more than the limit of {MAX_DECISION_BYTES}",
374 t.len()
375 ));
376 continue;
377 }
378 Ok(t) => t,
379 Err(e) => {
380 set.errors.push(format!("{rel}: {e}"));
381 continue;
382 }
383 };
384 match parse_decision(&text, &stem, &rel) {
385 Ok(d) if set.decisions.iter().any(|o| o.id == d.id) => {
386 set.errors.push(format!("{rel}: duplicate id `{}`", d.id));
387 }
388 Ok(d) => set.decisions.push(d),
389 Err(e) => set.errors.push(format!("{rel}: {e}")),
390 }
391 }
392 set.decisions.sort_by(|a, b| a.id.cmp(&b.id));
393 set.errors.sort();
394 set
395}
396
397fn repo_names(root: &Path) -> Vec<String> {
400 let mut names = Vec::new();
401 if let Some(name) = root.file_name() {
402 names.push(name.to_string_lossy().to_lowercase());
403 }
404 if let Some(url) = origin_url(root) {
405 let base = url
406 .trim_end_matches('/')
407 .rsplit(['/', ':'])
408 .next()
409 .unwrap_or_default()
410 .trim_end_matches(".git")
411 .to_lowercase();
412 if !base.is_empty() && !names.contains(&base) {
413 names.push(base);
414 }
415 }
416 names
417}
418
419fn git_common_dir(root: &Path) -> Option<PathBuf> {
420 let dot_git = root.join(".git");
421 if dot_git.is_dir() {
422 return Some(dot_git);
423 }
424 let text = std::fs::read_to_string(&dot_git).ok()?;
425 let gitdir = PathBuf::from(text.trim().strip_prefix("gitdir:")?.trim());
426 let gitdir = if gitdir.is_absolute() {
427 gitdir
428 } else {
429 root.join(gitdir)
430 };
431 match std::fs::read_to_string(gitdir.join("commondir")) {
432 Ok(common) => {
433 let common = PathBuf::from(common.trim());
434 Some(if common.is_absolute() {
435 common
436 } else {
437 gitdir.join(common)
438 })
439 }
440 Err(_) => Some(gitdir),
441 }
442}
443
444fn origin_url(root: &Path) -> Option<String> {
445 let config = std::fs::read_to_string(git_common_dir(root)?.join("config")).ok()?;
446 let mut in_origin = false;
447 for line in config.lines() {
448 let line = line.trim();
449 if line.starts_with('[') {
450 in_origin = line == "[remote \"origin\"]";
451 } else if in_origin
452 && let Some((key, value)) = line.split_once('=')
453 && key.trim() == "url"
454 {
455 return Some(value.trim().to_string());
456 }
457 }
458 None
459}
460
461pub fn find_root(path: &Path) -> Option<PathBuf> {
464 let start = if path.is_dir() { path } else { path.parent()? };
465 for dir in start.ancestors() {
466 if dir.join(DECISIONS_DIR).is_dir() {
467 return Some(dir.to_path_buf());
468 }
469 if dir.join(".git").exists() {
470 return None;
471 }
472 }
473 None
474}
475
476pub fn relative_path(root: &Path, path: &Path) -> Option<String> {
478 let abs = if path.is_absolute() {
479 path.to_path_buf()
480 } else {
481 root.join(path)
482 };
483 let rel: PathBuf = if let Ok(rel) = abs.strip_prefix(root) {
484 rel.to_path_buf()
485 } else {
486 let canon_root = root.canonicalize().ok()?;
489 let canon = abs.canonicalize().ok().or_else(|| {
490 let parent = abs.parent()?.canonicalize().ok()?;
491 Some(parent.join(abs.file_name()?))
492 })?;
493 canon.strip_prefix(&canon_root).ok()?.to_path_buf()
494 };
495 let s = rel.to_string_lossy().replace('\\', "/");
496 (!s.is_empty()).then_some(s)
497}
498
499pub fn render_lines(decisions: &[&Decision]) -> String {
503 let mut out = String::new();
504 for d in decisions.iter().take(MAX_SUFFIX_DECISIONS) {
505 let line = format!("{}: {} — {}", d.id, d.title.trim(), d.one_line());
506 out.push_str(&truncate_chars(&line, MAX_SUFFIX_LINE_CHARS * 2));
507 out.push('\n');
508 }
509 if decisions.len() > MAX_SUFFIX_DECISIONS {
510 out.push_str(&format!(
511 "(+{} more: lean-ctx decisions list)\n",
512 decisions.len() - MAX_SUFFIX_DECISIONS
513 ));
514 }
515 out
516}
517
518pub fn suffix_for_paths(set: &DecisionSet, rel_paths: &[&str]) -> String {
521 let mut hits: Vec<&Decision> = Vec::new();
522 for p in rel_paths {
523 for d in set.for_path(p) {
524 if !hits.iter().any(|h| h.id == d.id) {
525 hits.push(d);
526 }
527 }
528 }
529 if hits.is_empty() {
530 return String::new();
531 }
532 hits.sort_by(|a, b| a.id.cmp(&b.id));
533 format!("\n\n{SUFFIX_HEADER}\n{}", render_lines(&hits))
534 .trim_end()
535 .to_string()
536}
537
538pub fn suffix_for_file(file: &Path) -> String {
541 let Some(root) = find_root(file) else {
542 return String::new();
543 };
544 let Some(rel) = relative_path(&root, file) else {
545 return String::new();
546 };
547 suffix_for_paths(&load(&root), &[rel.as_str()])
548}
549
550pub fn suffix_for_read(file: &str) -> String {
554 if crate::core::policy::runtime::is_active() {
555 return String::new();
556 }
557 suffix_for_file(Path::new(file))
558}
559
560pub fn suffix_for_root_paths(root: &Path, rel_paths: &[&str]) -> String {
562 if !root.join(DECISIONS_DIR).is_dir() || rel_paths.is_empty() {
563 return String::new();
564 }
565 suffix_for_paths(&load(root), rel_paths)
566}
567
568const MAX_COMPOSE_PATHS: usize = 64;
570
571pub fn suffix_for_compose(root: &Path, text: &str) -> String {
574 if crate::core::policy::runtime::is_active() || !root.join(DECISIONS_DIR).is_dir() {
575 return String::new();
576 }
577 let mut paths: Vec<&str> = Vec::new();
578 for line in text.lines() {
579 let line = line.trim_start();
580 let candidate = match line.strip_prefix("File: ") {
581 Some(rest) => rest.split([' ', '(']).next().unwrap_or_default(),
582 None => match line.split_once(':') {
583 Some((p, rest)) if rest.starts_with(|c: char| c.is_ascii_digit()) => p,
584 _ => continue,
585 },
586 };
587 if candidate.is_empty()
588 || candidate.contains(char::is_whitespace)
589 || paths.contains(&candidate)
590 || !root.join(candidate).is_file()
591 {
592 continue;
593 }
594 paths.push(candidate);
595 if paths.len() >= MAX_COMPOSE_PATHS {
596 break;
597 }
598 }
599 suffix_for_root_paths(root, &paths)
600}
601
602pub fn describe(set: &DecisionSet, d: &Decision) -> String {
604 let mut out = format!(
605 "{} [{}] {}\n statement: {}\n",
606 d.id,
607 set.effective_status(d).as_str(),
608 d.title.trim(),
609 d.statement.trim()
610 );
611 if !d.rationale.trim().is_empty() {
612 out.push_str(&format!(" rationale: {}\n", d.rationale.trim()));
613 }
614 out.push_str(&format!(
615 " decided: {} by {}\n",
616 d.decided_at, d.decided_by
617 ));
618 if let (Some(kind), Some(r)) = (&d.source_kind, &d.source_ref) {
619 out.push_str(&format!(" source: {kind} {r}\n"));
620 }
621 if !d.supersedes.is_empty() {
622 out.push_str(&format!(" supersedes: {}\n", d.supersedes.join(", ")));
623 }
624 let scope = |v: &[String]| {
625 if v.is_empty() {
626 "*".to_string()
627 } else {
628 v.join(", ")
629 }
630 };
631 out.push_str(&format!(
632 " scope: repos={} paths={} symbols={}\n",
633 scope(&d.repos),
634 scope(&d.paths),
635 if d.symbols.is_empty() {
636 "-".to_string()
637 } else {
638 d.symbols.join(", ")
639 }
640 ));
641 for (i, c) in d.checks.iter().enumerate() {
642 out.push_str(&format!(
643 " check[{i}]: {} paths={}{}\n",
644 c.kind.as_str(),
645 scope(&c.paths),
646 c.pattern
647 .as_deref()
648 .map(|p| format!(" pattern={p}"))
649 .unwrap_or_default()
650 ));
651 }
652 out.push_str(&format!(" file: {}\n", d.file));
653 out
654}
655
656pub fn query_text(project_root: &Path, path: Option<&str>, symbol: Option<&str>) -> String {
660 let root = find_root(project_root).unwrap_or_else(|| project_root.to_path_buf());
661 let set = load(&root);
662 if set.is_empty() {
663 return format!(
664 "No decisions in this project ({DECISIONS_DIR}/*.toml). Create one: lean-ctx decisions new <slug>"
665 );
666 }
667 let (label, hits): (String, Vec<&Decision>) = match (path, symbol) {
668 (Some(p), _) => {
669 let rel = relative_path(&root, Path::new(p))
670 .unwrap_or_else(|| p.trim_start_matches("./").to_string());
671 let hits = set.for_path(&rel);
672 (format!("in scope of {rel}"), hits)
673 }
674 (None, Some(s)) => (format!("naming symbol {s}"), set.for_symbol(s)),
675 (None, None) => ("all".to_string(), set.decisions.iter().collect()),
676 };
677 let mut out = format!("Decisions {label}: {}\n", hits.len());
678 for d in hits {
679 out.push_str(&describe(&set, d));
680 }
681 if !set.errors.is_empty() {
682 out.push_str("Invalid records (not applied):\n");
683 for e in &set.errors {
684 out.push_str(&format!(" {e}\n"));
685 }
686 }
687 out.trim_end().to_string()
688}