Skip to main content

lean_ctx_server/tools/
startup.rs

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