lean_ctx_server/tools/
ctx_package.rs1use 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 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
77fn 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 #[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}