Skip to main content

lean_ctx/tools/
startup.rs

1use super::server::LeanCtxServer;
2
3#[derive(Clone, Debug, Default)]
4pub(super) struct StartupContext {
5    pub(super) project_root: Option<String>,
6    pub(super) shell_cwd: Option<String>,
7}
8
9/// Creates a new `LeanCtxServer` with default configuration.
10pub fn create_server() -> LeanCtxServer {
11    LeanCtxServer::new()
12}
13
14pub(super) fn has_project_marker(dir: &std::path::Path) -> bool {
15    crate::core::pathutil::has_project_marker(dir)
16}
17
18pub(super) fn is_suspicious_root(dir: &std::path::Path) -> bool {
19    crate::core::pathutil::is_agent_config_dir(dir)
20}
21
22pub(super) fn canonicalize_path(path: &std::path::Path) -> String {
23    crate::core::pathutil::safe_canonicalize_or_self(path)
24        .to_string_lossy()
25        .to_string()
26}
27
28pub(super) fn detect_startup_context(
29    explicit_project_root: Option<&str>,
30    startup_cwd: Option<&std::path::Path>,
31) -> StartupContext {
32    let shell_cwd = startup_cwd.map(canonicalize_path);
33    let project_root = explicit_project_root
34        .map(|root| canonicalize_path(std::path::Path::new(root)))
35        .or_else(|| {
36            startup_cwd
37                .and_then(maybe_derive_project_root_from_absolute)
38                .map(|p| canonicalize_path(&p))
39        });
40
41    let shell_cwd = match (shell_cwd, project_root.as_ref()) {
42        (Some(cwd), Some(root))
43            if std::path::Path::new(&cwd).starts_with(std::path::Path::new(root)) =>
44        {
45            Some(cwd)
46        }
47        (_, Some(root)) => Some(root.clone()),
48        (cwd, None) => cwd,
49    };
50
51    StartupContext {
52        project_root,
53        shell_cwd,
54    }
55}
56
57pub(super) fn maybe_derive_project_root_from_absolute(
58    abs: &std::path::Path,
59) -> Option<std::path::PathBuf> {
60    let mut cur = if abs.is_dir() {
61        abs.to_path_buf()
62    } else {
63        abs.parent()?.to_path_buf()
64    };
65    // #1437: keep walking up and return the OUTERMOST project marker, matching
66    // `detect_project_root`. The old "first match = return" behaviour caused
67    // monorepo sub-packages (own `package.json`, no `.git`) to become the
68    // derived root, which then poisoned the daemon session for all later queries.
69    let mut best: Option<std::path::PathBuf> = None;
70    loop {
71        if has_project_marker(&cur) {
72            best = Some(crate::core::pathutil::safe_canonicalize_or_self(&cur));
73        }
74        if !cur.pop() {
75            break;
76        }
77    }
78    best
79}
80
81/// Incremental background consolidation: import only session items newer than the
82/// per-session watermark, advancing it after a successful save. Delegates to the
83/// canonical engine ([`crate::tools::ctx_knowledge::consolidate_project_knowledge_with`]),
84/// which loads the session for the *requested* project root (cwd bug #2362), runs
85/// under the shared knowledge lock (#326) and reclaims history losslessly (#995).
86pub(crate) fn auto_consolidate_knowledge(project_root: &str) {
87    let _ = crate::tools::ctx_knowledge::consolidate_project_knowledge_with(
88        project_root,
89        &crate::core::consolidation_engine::ConsolidateOptions::incremental_auto(),
90    );
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn derive_root_promotes_to_outermost_marker_in_monorepo() {
99        // #1437: in a monorepo where sub-packages carry their own package.json,
100        // the derived root must be the outermost marker (repo root with .git),
101        // not the nearest sub-package marker.
102        let tmp = tempfile::tempdir().unwrap();
103        let repo = tmp.path().join("repo");
104        let sub = repo.join("modules").join("payroll");
105        std::fs::create_dir_all(&sub).unwrap();
106        // Repo root has .git + package.json
107        std::fs::create_dir(repo.join(".git")).unwrap();
108        std::fs::write(repo.join("package.json"), "{}").unwrap();
109        // Sub-package has only package.json (no .git)
110        std::fs::write(sub.join("package.json"), "{}").unwrap();
111
112        let result = maybe_derive_project_root_from_absolute(&sub);
113        assert!(result.is_some(), "should find a root");
114        let root = result.unwrap();
115        let canonical_repo = crate::core::pathutil::safe_canonicalize_or_self(&repo);
116        assert_eq!(
117            root, canonical_repo,
118            "should return repo root, not sub-package"
119        );
120    }
121
122    #[test]
123    fn derive_root_returns_nearest_when_no_outer_marker() {
124        // When there is no outer marker (standalone sub-package), the nearest
125        // marker is the correct root.
126        let tmp = tempfile::tempdir().unwrap();
127        let pkg = tmp.path().join("standalone");
128        std::fs::create_dir_all(&pkg).unwrap();
129        std::fs::write(pkg.join("package.json"), "{}").unwrap();
130
131        let result = maybe_derive_project_root_from_absolute(&pkg);
132        assert!(result.is_some());
133        let root = result.unwrap();
134        let canonical = crate::core::pathutil::safe_canonicalize_or_self(&pkg);
135        assert_eq!(root, canonical);
136    }
137}