1use std::collections::BTreeSet;
12use std::path::{Path, PathBuf};
13use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
14
15use regex::Regex;
16use serde_json::{Value, json};
17
18use crate::okfindex::FileStat;
19
20pub const CONFIG_FILE: &str = "index.jsonc";
21pub const PROVIDER_OKF: &str = "okf-markdown";
22pub const PROVIDER_OKF_VERSION: u64 = 1;
23pub const DEFAULT_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
24pub const DEFAULT_DEBOUNCE_MS: u64 = 150;
25pub const DEFAULT_AUDIT_SECONDS: u64 = 300;
26pub const DEFAULT_IDLE_SECONDS: u64 = 3600;
27pub const MAX_AUTOMATIC_DAEMON_MEMORY_BYTES: u64 = 2 * 1024 * 1024 * 1024;
28
29const DEFAULT_EXCLUDES: &[&str] = &[
30 ".git/**",
31 ".ct/okf/**",
32 "target/**",
33 "node_modules/**",
34 "**/*.db",
35 "**/*.sqlite*",
36];
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Origin {
40 Derived,
41 Project,
42}
43
44impl Origin {
45 pub fn label(self) -> &'static str {
46 match self {
47 Origin::Derived => "derived",
48 Origin::Project => "project",
49 }
50 }
51}
52
53#[derive(Debug, Clone)]
54pub struct Scope {
55 pub root: PathBuf,
56 pub provider: String,
57 pub include: Vec<String>,
58 pub exclude: Vec<String>,
59 pub origin: Origin,
60}
61
62#[derive(Debug, Clone)]
63pub struct Plan {
64 pub project: PathBuf,
65 pub scopes: Vec<Scope>,
66 pub exclude: Vec<String>,
67 pub watch: bool,
68 pub debounce_ms: u64,
69 pub audit_seconds: u64,
70 pub idle_seconds: u64,
71 pub max_file_bytes: u64,
72 pub system_memory_bytes: u64,
73 pub daemon_memory_limit_bytes: u64,
74 pub daemon_memory_limit_automatic: bool,
75 pub config_path: PathBuf,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Decision {
80 pub included: bool,
81 pub reason: &'static str,
82 pub provider: Option<String>,
83 pub scope_root: Option<PathBuf>,
84 pub matched: Option<String>,
85}
86
87impl Decision {
88 fn no(reason: &'static str) -> Decision {
89 Decision {
90 included: false,
91 reason,
92 provider: None,
93 scope_root: None,
94 matched: None,
95 }
96 }
97
98 pub fn to_json(&self, path: &Path) -> Value {
99 json!({
100 "path": path,
101 "decision": if self.included { "INCLUDED" } else { "EXCLUDED" },
102 "reason": self.reason,
103 "provider": self.provider,
104 "scope_root": self.scope_root,
105 "matched": self.matched,
106 })
107 }
108}
109
110#[derive(Debug, Default, Clone)]
111pub struct ScanMetrics {
112 pub entries_visited: usize,
113 pub files_considered: usize,
114 pub files_included: usize,
115 pub logical_bytes: u64,
116 pub elapsed_ms: u64,
117}
118
119impl ScanMetrics {
120 pub fn to_json(&self) -> Value {
121 json!({
122 "entries_visited": self.entries_visited,
123 "files_considered": self.files_considered,
124 "files_included": self.files_included,
125 "logical_bytes": self.logical_bytes,
126 "elapsed_ms": self.elapsed_ms,
127 })
128 }
129}
130
131fn string_array(value: Option<&Value>, field: &str) -> Result<Vec<String>, String> {
132 let Some(value) = value else {
133 return Ok(Vec::new());
134 };
135 let arr = value
136 .as_array()
137 .ok_or_else(|| format!("{field} must be an array of strings"))?;
138 arr.iter()
139 .map(|v| {
140 v.as_str()
141 .map(str::to_string)
142 .ok_or_else(|| format!("{field} must contain only strings"))
143 })
144 .collect()
145}
146
147fn positive_u64(
148 obj: &serde_json::Map<String, Value>,
149 field: &str,
150 default: u64,
151) -> Result<u64, String> {
152 match obj.get(field) {
153 None => Ok(default),
154 Some(v) => v
155 .as_u64()
156 .filter(|n| *n > 0)
157 .ok_or_else(|| format!("{field} must be a positive integer")),
158 }
159}
160
161pub fn automatic_daemon_memory_limit(system_bytes: u64) -> u64 {
165 if system_bytes == 0 {
166 MAX_AUTOMATIC_DAEMON_MEMORY_BYTES
167 } else {
168 (system_bytes / 20).clamp(1, MAX_AUTOMATIC_DAEMON_MEMORY_BYTES)
169 }
170}
171
172fn system_memory_bytes() -> u64 {
173 let mut system = sysinfo::System::new();
174 system.refresh_memory();
175 system.total_memory()
176}
177
178impl Plan {
179 pub fn load(project: &Path, detected_roots: &[PathBuf]) -> Result<Plan, String> {
181 let project = std::fs::canonicalize(project).unwrap_or_else(|_| project.to_path_buf());
182 let config_path = project.join(".ct").join(CONFIG_FILE);
183 let parsed = match std::fs::read_to_string(&config_path) {
184 Ok(text) => Some(
185 jsonc_parser::parse_to_serde_value(&text, &jsonc_parser::ParseOptions::default())
186 .map_err(|e| format!("{}: {e}", config_path.display()))?
187 .ok_or_else(|| format!("{}: empty document", config_path.display()))?,
188 ),
189 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
190 Err(e) => return Err(format!("{}: {e}", config_path.display())),
191 };
192
193 let system_memory_bytes = system_memory_bytes();
194 let mut plan = Plan {
195 project: project.clone(),
196 scopes: Vec::new(),
197 exclude: DEFAULT_EXCLUDES.iter().map(|s| (*s).to_string()).collect(),
198 watch: true,
199 debounce_ms: DEFAULT_DEBOUNCE_MS,
200 audit_seconds: DEFAULT_AUDIT_SECONDS,
201 idle_seconds: DEFAULT_IDLE_SECONDS,
202 max_file_bytes: DEFAULT_MAX_FILE_BYTES,
203 system_memory_bytes,
204 daemon_memory_limit_bytes: automatic_daemon_memory_limit(system_memory_bytes),
205 daemon_memory_limit_automatic: true,
206 config_path,
207 };
208
209 let explicit_scopes = parsed
210 .as_ref()
211 .and_then(|v| v.as_object())
212 .is_some_and(|o| o.contains_key("scopes"));
213
214 if let Some(v) = parsed {
215 let obj = v
216 .as_object()
217 .ok_or_else(|| format!("{}: root must be an object", plan.config_path.display()))?;
218 let version = obj.get("version").and_then(Value::as_u64).unwrap_or(1);
219 if version != 1 {
220 return Err(format!(
221 "{}: unsupported version {version}",
222 plan.config_path.display()
223 ));
224 }
225 plan.watch = obj.get("watch").and_then(Value::as_bool).unwrap_or(true);
226 plan.debounce_ms = positive_u64(obj, "debounce_ms", DEFAULT_DEBOUNCE_MS)?;
227 plan.audit_seconds = positive_u64(obj, "audit_seconds", DEFAULT_AUDIT_SECONDS)?;
228 plan.idle_seconds = positive_u64(obj, "idle_seconds", DEFAULT_IDLE_SECONDS)?;
229 plan.max_file_bytes = positive_u64(obj, "max_file_bytes", DEFAULT_MAX_FILE_BYTES)?;
230 if obj.contains_key("max_daemon_memory_bytes") {
231 plan.daemon_memory_limit_bytes = positive_u64(
232 obj,
233 "max_daemon_memory_bytes",
234 plan.daemon_memory_limit_bytes,
235 )?;
236 plan.daemon_memory_limit_automatic = false;
237 }
238 plan.exclude
239 .extend(string_array(obj.get("exclude"), "exclude")?);
240
241 if explicit_scopes {
242 let scopes = obj
243 .get("scopes")
244 .and_then(Value::as_array)
245 .ok_or("scopes must be an array")?;
246 for (i, raw) in scopes.iter().enumerate() {
247 let scope = raw
248 .as_object()
249 .ok_or_else(|| format!("scopes[{i}] must be an object"))?;
250 let root = scope
251 .get("root")
252 .and_then(Value::as_str)
253 .ok_or_else(|| format!("scopes[{i}].root must be a string"))?;
254 let provider = scope
255 .get("provider")
256 .and_then(Value::as_str)
257 .ok_or_else(|| format!("scopes[{i}].provider must be a string"))?;
258 if provider != PROVIDER_OKF {
259 return Err(format!(
260 "scopes[{i}]: unsupported provider '{provider}' (registered: {PROVIDER_OKF})"
261 ));
262 }
263 let mut include = string_array(scope.get("include"), "scope include")?;
264 if include.is_empty() {
265 include.push("**/*.md".to_string());
266 }
267 let root = PathBuf::from(root);
268 let root = if root.is_absolute() {
269 root
270 } else {
271 project.join(root)
272 };
273 plan.scopes.push(Scope {
274 root: std::fs::canonicalize(&root).unwrap_or(root),
275 provider: provider.to_string(),
276 include,
277 exclude: string_array(scope.get("exclude"), "scope exclude")?,
278 origin: Origin::Project,
279 });
280 }
281 }
282 }
283
284 if !explicit_scopes {
285 for root in detected_roots {
286 let root = std::fs::canonicalize(root).unwrap_or_else(|_| root.clone());
287 plan.scopes.push(Scope {
288 root,
289 provider: PROVIDER_OKF.to_string(),
290 include: vec!["**/*.md".to_string()],
291 exclude: Vec::new(),
292 origin: Origin::Derived,
293 });
294 }
295 }
296 let mut seen = BTreeSet::new();
297 plan.exclude.retain(|pattern| seen.insert(pattern.clone()));
298 Ok(plan)
299 }
300
301 pub fn to_json(&self) -> Value {
302 json!({
303 "config": self.config_path,
304 "watch": self.watch,
305 "debounce_ms": self.debounce_ms,
306 "audit_seconds": self.audit_seconds,
307 "idle_seconds": self.idle_seconds,
308 "max_file_bytes": self.max_file_bytes,
309 "system_memory_bytes": self.system_memory_bytes,
310 "max_daemon_memory_bytes": self.daemon_memory_limit_bytes,
311 "daemon_memory_limit_origin": if self.daemon_memory_limit_automatic { "automatic" } else { "project" },
312 "exclude": self.exclude,
313 "providers": [{"id": PROVIDER_OKF, "version": PROVIDER_OKF_VERSION}],
314 "scopes": self.scopes.iter().map(|s| json!({
315 "root": s.root,
316 "provider": s.provider,
317 "provider_version": PROVIDER_OKF_VERSION,
318 "include": s.include,
319 "exclude": s.exclude,
320 "origin": s.origin.label(),
321 })).collect::<Vec<_>>(),
322 })
323 }
324
325 pub fn config_json(&self) -> Value {
328 let mut config = json!({
329 "version": 1,
330 "watch": self.watch,
331 "debounce_ms": self.debounce_ms,
332 "audit_seconds": self.audit_seconds,
333 "idle_seconds": self.idle_seconds,
334 "max_file_bytes": self.max_file_bytes,
335 "scopes": self.scopes.iter().map(|s| json!({
336 "root": s.root.strip_prefix(&self.project).map(path_key).unwrap_or_else(|_| path_key(&s.root)),
337 "provider": s.provider,
338 "include": s.include,
339 "exclude": s.exclude,
340 })).collect::<Vec<_>>(),
341 "exclude": self.exclude,
342 });
343 if !self.daemon_memory_limit_automatic {
344 config.as_object_mut().expect("config is an object").insert(
345 "max_daemon_memory_bytes".to_string(),
346 json!(self.daemon_memory_limit_bytes),
347 );
348 }
349 config
350 }
351
352 pub fn decide(&self, path: &Path, metadata: Option<&std::fs::Metadata>) -> Decision {
355 let path = if path.is_absolute() {
356 path.to_path_buf()
357 } else {
358 self.project.join(path)
359 };
360 let index_dir = self.project.join(".ct").join("okf");
361 if path.starts_with(&index_dir) {
362 return Decision::no("hard-excluded");
363 }
364 if let Some(meta) = metadata
365 && !meta.is_file()
366 {
367 return Decision::no("not-regular");
368 }
369
370 for scope in &self.scopes {
371 let Ok(rel) = path.strip_prefix(&scope.root) else {
372 continue;
373 };
374 let rel = path_key(rel);
375 let project_rel = path
376 .strip_prefix(&self.project)
377 .map(path_key)
378 .unwrap_or_else(|_| rel.clone());
379 if let Some(pat) = self.exclude.iter().find(|p| glob_matches(p, &project_rel)) {
380 return Decision {
381 included: false,
382 reason: "excluded",
383 provider: Some(scope.provider.clone()),
384 scope_root: Some(scope.root.clone()),
385 matched: Some(pat.clone()),
386 };
387 }
388 if let Some(pat) = scope.exclude.iter().find(|p| glob_matches(p, &rel)) {
389 return Decision {
390 included: false,
391 reason: "excluded",
392 provider: Some(scope.provider.clone()),
393 scope_root: Some(scope.root.clone()),
394 matched: Some(pat.clone()),
395 };
396 }
397 let matched = scope
398 .include
399 .iter()
400 .find(|p| glob_matches(p, &rel))
401 .cloned();
402 if matched.is_none() {
403 continue;
404 }
405 if scope.provider != PROVIDER_OKF {
406 return Decision::no("unsupported-provider");
407 }
408 if metadata.is_some_and(|meta| meta.len() > self.max_file_bytes) {
409 return Decision {
410 included: false,
411 reason: "too-large",
412 provider: Some(scope.provider.clone()),
413 scope_root: Some(scope.root.clone()),
414 matched,
415 };
416 }
417 let filename = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
418 let markdown = path.extension().and_then(|s| s.to_str()) == Some("md");
419 if !markdown || crate::okf::is_reserved(filename) {
420 return Decision {
421 included: false,
422 reason: "unsupported-type",
423 provider: Some(scope.provider.clone()),
424 scope_root: Some(scope.root.clone()),
425 matched,
426 };
427 }
428 return Decision {
429 included: true,
430 reason: "included",
431 provider: Some(scope.provider.clone()),
432 scope_root: Some(scope.root.clone()),
433 matched,
434 };
435 }
436 Decision::no("outside-scope")
437 }
438}
439
440pub fn path_key(path: &Path) -> String {
441 path.to_string_lossy()
442 .replace('\\', "/")
443 .trim_start_matches("./")
444 .to_string()
445}
446
447fn glob_regex(pattern: &str) -> Result<Regex, regex::Error> {
448 let p = pattern.replace('\\', "/");
449 let mut out = String::from("^");
450 let mut chars = p.chars().peekable();
451 while let Some(c) = chars.next() {
452 match c {
453 '*' if chars.peek() == Some(&'*') => {
454 chars.next();
455 if chars.peek() == Some(&'/') {
456 chars.next();
457 out.push_str("(?:.*/)?");
458 } else {
459 out.push_str(".*");
460 }
461 }
462 '*' => out.push_str("[^/]*"),
463 '?' => out.push_str("[^/]"),
464 '/' => out.push('/'),
465 other => out.push_str(®ex::escape(&other.to_string())),
466 }
467 }
468 out.push('$');
469 Regex::new(&out)
470}
471
472pub fn glob_matches(pattern: &str, path: &str) -> bool {
473 glob_regex(pattern).is_ok_and(|r| r.is_match(path))
474}
475
476fn mtime_ns(meta: &std::fs::Metadata) -> u64 {
477 meta.modified()
478 .ok()
479 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
480 .map(|d| d.as_nanos() as u64)
481 .unwrap_or(0)
482}
483
484pub fn file_stat(plan: &Plan, path: &Path, meta: &std::fs::Metadata) -> FileStat {
485 let key = path
486 .strip_prefix(&plan.project)
487 .map(path_key)
488 .unwrap_or_else(|_| path_key(path));
489 FileStat {
490 key,
491 path: path.to_path_buf(),
492 mtime_ns: mtime_ns(meta),
493 size: meta.len(),
494 }
495}
496
497pub fn scan(plan: &Plan) -> (Vec<FileStat>, ScanMetrics) {
500 let started = Instant::now();
501 let mut seen = BTreeSet::new();
502 let mut files = Vec::new();
503 let mut metrics = ScanMetrics::default();
504 for scope in &plan.scopes {
505 for item in ignore::WalkBuilder::new(&scope.root).hidden(false).build() {
506 metrics.entries_visited += 1;
507 let Ok(entry) = item else { continue };
508 if !entry.file_type().is_some_and(|t| t.is_file()) {
509 continue;
510 }
511 metrics.files_considered += 1;
512 let path = entry.path();
513 let Ok(meta) = std::fs::metadata(path) else {
514 continue;
515 };
516 let decision = plan.decide(path, Some(&meta));
517 if !decision.included {
518 continue;
519 }
520 let stat = file_stat(plan, path, &meta);
521 let key = stat.key.clone();
522 if !seen.insert(key.clone()) {
523 continue;
524 }
525 metrics.files_included += 1;
526 metrics.logical_bytes += meta.len();
527 files.push(stat);
528 }
529 }
530 metrics.elapsed_ms = started.elapsed().as_millis() as u64;
531 (files, metrics)
532}
533
534pub fn unix_millis() -> u64 {
535 SystemTime::now()
536 .duration_since(UNIX_EPOCH)
537 .unwrap_or(Duration::ZERO)
538 .as_millis() as u64
539}
540
541pub fn directory_bytes(path: &Path) -> u64 {
542 ignore::WalkBuilder::new(path)
543 .hidden(false)
544 .ignore(false)
545 .git_ignore(false)
546 .git_global(false)
547 .git_exclude(false)
548 .build()
549 .filter_map(Result::ok)
550 .filter_map(|e| e.metadata().ok())
551 .filter(|m| m.is_file())
552 .map(|m| m.len())
553 .sum()
554}
555
556#[cfg(test)]
557mod tests {
558 use super::*;
559
560 #[test]
561 fn doublestar_and_star_have_path_semantics() {
562 assert!(glob_matches("**/*.md", "a.md"));
563 assert!(glob_matches("**/*.md", "deep/a.md"));
564 assert!(!glob_matches("*.md", "deep/a.md"));
565 assert!(glob_matches("archive/**", "archive/old/a.md"));
566 }
567
568 #[test]
569 fn provider_boundary_rejects_unknown_and_reserved_files() {
570 let root = std::env::temp_dir().join("ct-indexing-policy-test");
571 let plan = Plan {
572 project: root.clone(),
573 scopes: vec![Scope {
574 root: root.join("knowledge"),
575 provider: PROVIDER_OKF.to_string(),
576 include: vec!["**/*".to_string()],
577 exclude: vec![],
578 origin: Origin::Derived,
579 }],
580 exclude: vec![],
581 watch: true,
582 debounce_ms: 1,
583 audit_seconds: 1,
584 idle_seconds: 1,
585 max_file_bytes: 100,
586 system_memory_bytes: 8 * 1024 * 1024 * 1024,
587 daemon_memory_limit_bytes: MAX_AUTOMATIC_DAEMON_MEMORY_BYTES,
588 daemon_memory_limit_automatic: true,
589 config_path: root.join(".ct/index.jsonc"),
590 };
591 assert_eq!(
592 plan.decide(&root.join("knowledge/a.db"), None).reason,
593 "unsupported-type"
594 );
595 assert_eq!(
596 plan.decide(&root.join("knowledge/index.md"), None).reason,
597 "unsupported-type"
598 );
599 assert!(plan.decide(&root.join("knowledge/a.md"), None).included);
600 }
601
602 #[test]
603 fn automatic_memory_limit_is_five_percent_capped_at_two_gib() {
604 assert_eq!(
605 automatic_daemon_memory_limit(8 * 1024 * 1024 * 1024),
606 429_496_729
607 );
608 assert_eq!(
609 automatic_daemon_memory_limit(128 * 1024 * 1024 * 1024),
610 MAX_AUTOMATIC_DAEMON_MEMORY_BYTES
611 );
612 assert_eq!(
613 automatic_daemon_memory_limit(0),
614 MAX_AUTOMATIC_DAEMON_MEMORY_BYTES
615 );
616 }
617
618 #[test]
619 fn idle_and_memory_limits_are_effective_but_only_overrides_persist() {
620 let project = std::env::temp_dir().join(format!(
621 "ct-indexing-config-{}-{}",
622 std::process::id(),
623 unix_millis()
624 ));
625 std::fs::create_dir_all(project.join(".ct")).unwrap();
626 let automatic = Plan::load(&project, &[]).unwrap();
627 assert_eq!(automatic.idle_seconds, 3600);
628 assert!(automatic.daemon_memory_limit_automatic);
629 assert!(
630 automatic
631 .config_json()
632 .get("max_daemon_memory_bytes")
633 .is_none()
634 );
635
636 std::fs::write(
637 project.join(".ct/index.jsonc"),
638 "{\"idle_seconds\": 42, \"max_daemon_memory_bytes\": 123456}\n",
639 )
640 .unwrap();
641 let configured = Plan::load(&project, &[]).unwrap();
642 assert_eq!(configured.idle_seconds, 42);
643 assert_eq!(configured.daemon_memory_limit_bytes, 123456);
644 assert!(!configured.daemon_memory_limit_automatic);
645 assert_eq!(configured.config_json()["max_daemon_memory_bytes"], 123456);
646 let _ = std::fs::remove_dir_all(project);
647 }
648}