Skip to main content

coding_tools/
indexing.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! Conservative index eligibility and observability.
5//!
6//! A filesystem event only says that a path may have changed. This module owns
7//! the separate decision about whether that path may enter an index at all. The
8//! first registered provider is the existing OKF Markdown indexer; unknown
9//! formats are deliberately unsupported rather than fed to a generic tokenizer.
10
11use 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
161/// Default daemon ceiling: five percent of physical RAM, capped at 2 GiB.
162/// When the platform cannot report RAM, the fixed cap is the conservative
163/// usable fallback.
164pub 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    /// Load `.ct/index.jsonc`, deriving conservative OKF scopes when absent.
180    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    /// Materializable project configuration for the current effective scopes.
326    /// Paths beneath the project are written relatively for portability.
327    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    /// Explain whether `path` is eligible. `metadata` may be omitted for a
353    /// deleted path; filename and scope policy can still be explained.
354    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(&regex::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
497/// Reconcile candidates from the effective scopes. The provider whitelist is
498/// applied before a path is returned to the indexer.
499pub 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}