Skip to main content

lean_ctx_server/tools/
ctx_package.rs

1// SPDX-License-Identifier: Apache-2.0
2//! `ctx_package` business logic (#293): save/resume portable context packages.
3
4use std::path::Path;
5
6use lean_ctx::core::context_package;
7use lean_ctx::core::session::SessionState;
8
9pub fn handle(
10    project_root: &str,
11    session: Option<&SessionState>,
12    action: &str,
13    path: Option<&str>,
14    agent_id: Option<&str>,
15    description: Option<&str>,
16) -> String {
17    match action.trim() {
18        "save" => handle_save(project_root, session, path, agent_id, description),
19        "resume" => handle_resume(project_root, session, path),
20        "list" => handle_list(project_root),
21        "info" => handle_info(path),
22        other => format!(
23            "ERR: unknown package action '{other}'. Use: save | resume <path> | list | info <path>"
24        ),
25    }
26}
27
28fn handle_save(
29    project_root: &str,
30    session: Option<&SessionState>,
31    path: Option<&str>,
32    agent_id: Option<&str>,
33    description: Option<&str>,
34) -> String {
35    let Some(session) = session else {
36        return "ERR: no active session to save".to_string();
37    };
38    let output_path = path.map(Path::new);
39    match context_package::save_package(session, project_root, agent_id, description, output_path) {
40        Ok(p) => format!("package saved: {}", p.display()),
41        Err(e) => format!("ERR: {e}"),
42    }
43}
44
45fn handle_resume(project_root: &str, session: Option<&SessionState>, path: Option<&str>) -> String {
46    let Some(path_str) = path else {
47        return "ERR: resume requires a path to the .ctx.json package".to_string();
48    };
49    let pkg_path = Path::new(path_str);
50    if !pkg_path.exists() {
51        // Try the default packages directory.
52        let hash = lean_ctx::core::project_hash::hash_project_root(project_root);
53        let alt = lean_ctx::core::data_dir::lean_ctx_data_dir()
54            .unwrap_or_else(|_| std::path::PathBuf::from(".lean-ctx"))
55            .join("packages")
56            .join(hash)
57            .join(path_str);
58        if !alt.exists() {
59            return format!("ERR: package not found: {path_str}");
60        }
61        return do_resume(session, &alt);
62    }
63    do_resume(session, pkg_path)
64}
65
66fn do_resume(session: Option<&SessionState>, path: &Path) -> String {
67    let mut target = match session {
68        Some(base) => base.clone(),
69        None => SessionState::new(),
70    };
71    match context_package::resume_package(&mut target, path) {
72        Ok(report) => report.format(),
73        Err(e) => format!("ERR: {e}"),
74    }
75}
76
77/// A package's summary line carries stored text (its description, or the
78/// task description of the session it was saved from). A package file has no
79/// source binding, so the line is served under the originless rule (G04):
80/// withheld while protection is active in any layer, re-admitted under
81/// today's content rules otherwise. Counts and the file name are metadata.
82fn served_summary(pkg: &context_package::ContextPackage) -> String {
83    crate::tools::admitted_sources::serve_originless(&pkg.summary_line(), "package summary")
84        .unwrap_or_else(|reason| format!("[withheld: {reason}]"))
85}
86
87fn handle_list(project_root: &str) -> String {
88    let hash = lean_ctx::core::project_hash::hash_project_root(project_root);
89    let dir = lean_ctx::core::data_dir::lean_ctx_data_dir()
90        .unwrap_or_else(|_| std::path::PathBuf::from(".lean-ctx"))
91        .join("packages")
92        .join(hash);
93    if !dir.exists() {
94        return "No saved packages yet.".to_string();
95    }
96    let mut entries: Vec<String> = Vec::new();
97    if let Ok(rd) = std::fs::read_dir(&dir) {
98        for entry in rd.flatten() {
99            let p = entry.path();
100            if p.extension().and_then(|e| e.to_str()) == Some("json")
101                && let Ok(json) = std::fs::read_to_string(&p)
102                && let Ok(pkg) = serde_json::from_str::<context_package::ContextPackage>(&json)
103            {
104                entries.push(format!(
105                    "  {} — {}",
106                    p.file_name().unwrap_or_default().to_string_lossy(),
107                    served_summary(&pkg)
108                ));
109            }
110        }
111    }
112    if entries.is_empty() {
113        return "No saved packages yet.".to_string();
114    }
115    entries.sort();
116    format!("packages ({}):\n{}", entries.len(), entries.join("\n"))
117}
118
119fn handle_info(path: Option<&str>) -> String {
120    let Some(path_str) = path else {
121        return "ERR: info requires a path".to_string();
122    };
123    let p = Path::new(path_str);
124    if !p.exists() {
125        return format!("ERR: not found: {path_str}");
126    }
127    match std::fs::read_to_string(p) {
128        Ok(json) => match serde_json::from_str::<context_package::ContextPackage>(&json) {
129            Ok(pkg) => format!(
130                "format_version: {}\ncreated: {}\nproject: {}\n{}",
131                pkg.format_version,
132                pkg.created_at.format("%Y-%m-%d %H:%M"),
133                pkg.project_root,
134                served_summary(&pkg)
135            ),
136            Err(e) => format!("ERR: parse: {e}"),
137        },
138        Err(e) => format!("ERR: read: {e}"),
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use lean_ctx::core::policy::runtime::TestPolicyOverride;
146
147    /// G04: `info` and `list` printed a package's stored description as it
148    /// was saved. A package has no source binding, so its text follows the
149    /// originless rule: served in Community developer mode, withheld once a
150    /// project policy is active.
151    #[test]
152    fn a_package_summary_is_served_under_the_originless_rule() {
153        let _data = lean_ctx::core::data_dir::isolated_data_dir();
154        let project = tempfile::tempdir().unwrap();
155        let root = project.path().to_string_lossy().into_owned();
156        let file = project.path().join("handover.ctx.json");
157        let file_str = file.to_string_lossy().into_owned();
158        {
159            let _community = TestPolicyOverride::set(None);
160            let saved = handle(
161                &root,
162                Some(&SessionState::new()),
163                "save",
164                Some(&file_str),
165                None,
166                Some("widget release handover"),
167            );
168            assert!(saved.starts_with("package saved: "), "{saved}");
169            let info = handle_info(Some(&file_str));
170            assert!(info.contains("widget release handover"), "{info}");
171        }
172        let policy = lean_ctx::core::policy::load(
173            "name = \"ctx-package-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n",
174        )
175        .unwrap();
176        let _policy = TestPolicyOverride::set(Some(policy));
177        let info = handle_info(Some(&file_str));
178        assert!(info.starts_with("format_version: "), "{info}");
179        assert!(info.contains("[withheld: "), "{info}");
180        assert!(!info.contains("widget release handover"), "{info}");
181    }
182}