lean_ctx/core/context_package/
selection.rs1use std::collections::HashMap;
13use std::fmt::Write as _;
14
15use super::builder::{ContentScope, PackageBuilder};
16use super::content::PackageContent;
17use super::manifest::{PackageLayer, PackageManifest};
18use crate::core::knowledge::KnowledgeFact;
19
20pub(crate) const DEFAULT_LAYERS: &[&str] = &["knowledge", "graph", "session", "gotchas"];
22pub(crate) const SELECTABLE_LAYERS: &[&str] =
24 &["knowledge", "graph", "session", "patterns", "gotchas"];
25
26#[derive(Debug, Clone, Default)]
29pub struct PackSelection {
30 pub layers: Vec<String>,
31 pub categories: Vec<String>,
32 pub keys: Vec<String>,
33 pub facts: Vec<String>,
35}
36
37impl PackSelection {
38 pub(crate) fn validate(&self) -> Result<(), String> {
39 if let Some(unknown) = self
40 .layers
41 .iter()
42 .find(|layer| !SELECTABLE_LAYERS.contains(&layer.as_str()))
43 {
44 return Err(format!(
45 "unknown layer `{unknown}` (choose from {})",
46 SELECTABLE_LAYERS.join(", ")
47 ));
48 }
49 if let Some(bad) = self.facts.iter().find(|fact| {
50 fact.split_once('/')
51 .is_none_or(|(c, k)| c.is_empty() || k.is_empty())
52 }) {
53 return Err(format!("fact `{bad}` must be written as category/key"));
54 }
55 Ok(())
56 }
57
58 pub fn wants(&self, layer: &str) -> bool {
59 if self.layers.is_empty() {
60 DEFAULT_LAYERS.contains(&layer)
61 } else {
62 self.layers.iter().any(|l| l == layer)
63 }
64 }
65
66 fn filters_facts(&self) -> bool {
67 !(self.categories.is_empty() && self.keys.is_empty() && self.facts.is_empty())
68 }
69
70 fn admits(&self, fact: &KnowledgeFact) -> bool {
71 if !self.filters_facts() {
72 return true;
73 }
74 let named = self
75 .facts
76 .iter()
77 .any(|f| f.split_once('/') == Some((fact.category.as_str(), fact.key.as_str())));
78 let by_filter = !(self.categories.is_empty() && self.keys.is_empty())
79 && (self.categories.is_empty() || self.categories.contains(&fact.category))
80 && (self.keys.is_empty() || self.keys.contains(&fact.key));
81 named || by_filter
82 }
83}
84
85#[derive(Debug, Clone, Default)]
87pub struct PackRequest {
88 pub name: String,
89 pub version: String,
90 pub description: String,
91 pub author: Option<String>,
92 pub tags: Vec<String>,
93 pub level: u32,
94 pub scope: Option<String>,
95 pub private: bool,
96 pub redistributable: bool,
97 pub selection: PackSelection,
98}
99
100#[derive(Debug, Clone)]
102pub struct Withheld {
103 pub fact: String,
104 pub reason: String,
105}
106
107pub struct PackPlan {
109 pub manifest: PackageManifest,
110 pub content: PackageContent,
111 pub withheld: Vec<Withheld>,
113 pub not_selected: usize,
115 pub unmatched: Vec<String>,
117}
118
119pub fn plan(
121 project_root: &str,
122 request: &PackRequest,
123 session: Option<&crate::core::session::SessionState>,
124) -> Result<PackPlan, String> {
125 request.selection.validate()?;
126 let selection = request.selection.clone();
127
128 let facts = crate::core::knowledge::KnowledgeSnapshot::collect(project_root).facts;
130 let registry = super::LocalRegistry::open().ok();
131 let mut grants: HashMap<String, bool> = HashMap::new();
132 for source in facts.iter().filter_map(|f| f.imported_from.as_deref()) {
133 if let Some((name, version)) = super::rights::package_source(source) {
134 grants.entry(source.to_string()).or_insert_with(|| {
135 registry
136 .as_ref()
137 .is_some_and(|r| super::rights::source_grants(r, name, version))
138 });
139 }
140 }
141 let repackable = move |fact: &KnowledgeFact| {
142 fact.imported_from
143 .as_deref()
144 .and_then(|source| grants.get(source))
145 .is_none_or(|granted| *granted)
146 };
147
148 let wants_knowledge = selection.wants("knowledge") || selection.wants("patterns");
149 let mut withheld = Vec::new();
150 let mut not_selected = 0;
151 for fact in &facts {
152 if !wants_knowledge || !selection.admits(fact) {
153 not_selected += 1;
154 } else if !repackable(fact) {
155 withheld.push(Withheld {
156 fact: format!("{}/{}", fact.category, fact.key),
157 reason: format!(
158 "merged from {} — that package grants no redistribution",
159 fact.imported_from.as_deref().unwrap_or_default()
160 ),
161 });
162 }
163 }
164 let unmatched = selection
165 .facts
166 .iter()
167 .filter(|named| {
168 !facts
169 .iter()
170 .any(|f| named.split_once('/') == Some((f.category.as_str(), f.key.as_str())))
171 })
172 .cloned()
173 .collect();
174
175 let scope = ContentScope {
176 knowledge_extras: !selection.filters_facts(),
177 gotchas: selection.wants("gotchas"),
178 code: selection.wants("graph"),
179 facts: Some(Box::new({
180 let selection = selection.clone();
181 move |fact: &KnowledgeFact| {
182 wants_knowledge && selection.admits(fact) && repackable(fact)
183 }
184 })),
185 };
186
187 let mut builder = PackageBuilder::new(&request.name, &request.version)
188 .description(&request.description)
189 .tags(request.tags.clone())
190 .level(request.level)
191 .content_scope(scope);
192 if let Some(author) = &request.author {
193 builder = builder.author(author);
194 }
195 if let Some(scope) = &request.scope {
196 builder = builder.scope(scope);
197 }
198 if request.private {
199 builder = builder.private();
200 }
201 if request.redistributable {
202 builder = builder.redistributable();
203 }
204 if request.level >= 2 {
207 builder.build_context_graph(project_root);
208 }
209 if wants_knowledge {
210 builder = builder.add_knowledge_from_project(project_root);
211 }
212 if selection.wants("patterns") {
213 builder = builder.add_patterns_from_project(project_root);
214 }
215 if selection.wants("graph") {
216 builder = builder.add_graph_from_project(project_root);
217 }
218 if selection.wants("session")
219 && let Some(session) = session
220 {
221 builder = builder.add_session(session);
222 }
223 if selection.wants("gotchas") {
224 builder = builder.add_gotchas_from_project(project_root);
225 }
226 let (manifest, content) = builder.build()?;
227 Ok(PackPlan {
228 manifest,
229 content,
230 withheld,
231 not_selected,
232 unmatched,
233 })
234}
235
236fn short(text: &str, max: usize) -> String {
237 let line = text.lines().next().unwrap_or_default();
238 if line.chars().count() <= max && line.len() == text.len() {
239 return line.to_string();
240 }
241 let cut: String = line.chars().take(max).collect();
242 format!("{cut}…")
243}
244
245fn source_of(fact: &KnowledgeFact) -> String {
246 use crate::core::knowledge::FactOrigin;
247 let origin = match &fact.origin {
248 FactOrigin::Provider(p) => format!("provider {}/{}", p.provider, p.resource),
249 FactOrigin::Derived(_) => "derived".into(),
250 FactOrigin::Local => "written locally".into(),
251 FactOrigin::Unverified => "no recorded source".into(),
252 };
253 match &fact.imported_from {
254 Some(from) => format!("{origin}, merged from {from}"),
255 None => origin,
256 }
257}
258
259fn validity_of(fact: &KnowledgeFact) -> String {
260 match (fact.valid_from, fact.valid_until) {
261 (_, Some(until)) => format!(
262 "no longer current, valid until {}",
263 until.format("%Y-%m-%d")
264 ),
265 (Some(from), None) => format!("current since {}", from.format("%Y-%m-%d")),
266 (None, None) => "current".into(),
267 }
268}
269
270impl PackPlan {
271 pub fn render(&self, preview: bool) -> String {
275 let m = &self.manifest;
276 let c = &self.content;
277 let mut out = String::new();
278 if preview {
279 let _ = writeln!(
280 out,
281 "Preview of {}@{} — nothing was written",
282 m.name, m.version
283 );
284 }
285 let layers: Vec<_> = m.layers.iter().map(PackageLayer::as_str).collect();
286 let _ = writeln!(out, " Layers: {}", layers.join(", "));
287 if let Some(knowledge) = &c.knowledge {
288 let verified = knowledge
289 .facts
290 .iter()
291 .filter(|f| f.status() == crate::core::knowledge::FactStatus::Verified)
292 .count();
293 let _ = writeln!(
294 out,
295 " Knowledge facts: {} ({verified} verified, {} assumption)",
296 knowledge.facts.len(),
297 knowledge.facts.len() - verified
298 );
299 for fact in knowledge.facts.iter().filter(|_| preview) {
300 let _ = writeln!(
301 out,
302 " - [{}] {}/{}: {} (source: {}; {})",
303 fact.status().as_str(),
304 fact.category,
305 fact.key,
306 short(&fact.value, 80),
307 source_of(fact),
308 validity_of(fact)
309 );
310 }
311 if !knowledge.patterns.is_empty() || !knowledge.insights.is_empty() {
312 let _ = writeln!(
313 out,
314 " Knowledge patterns: {}, insights: {}",
315 knowledge.patterns.len(),
316 knowledge.insights.len()
317 );
318 }
319 }
320 if let Some(patterns) = &c.patterns {
321 let _ = writeln!(out, " Patterns: {}", patterns.patterns.len());
322 for p in patterns.patterns.iter().filter(|_| preview) {
323 let _ = writeln!(
324 out,
325 " - {}: {}",
326 p.pattern_type,
327 short(&p.description, 80)
328 );
329 }
330 }
331 if let Some(session) = &c.session {
332 let _ = writeln!(
333 out,
334 " Session: task {}; {} finding(s), {} decision(s), {} next step(s), {} file(s)",
335 session
336 .task_description
337 .as_deref()
338 .map_or_else(|| "(none)".to_string(), |t| format!("\"{}\"", short(t, 80))),
339 session.findings.len(),
340 session.decisions.len(),
341 session.next_steps.len(),
342 session.files_touched.len()
343 );
344 }
345 if let Some(graph) = &c.graph {
346 let _ = writeln!(
347 out,
348 " Code graph: {} node(s), {} edge(s)",
349 graph.nodes.len(),
350 graph.edges.len()
351 );
352 }
353 if let Some(graph) = &c.context_graph {
354 let _ = writeln!(
355 out,
356 " Context graph: {} node(s), {} edge(s)",
357 graph.nodes.len(),
358 graph.edges.len()
359 );
360 }
361 if let Some(gotchas) = &c.gotchas {
362 let _ = writeln!(out, " Gotchas: {}", gotchas.gotchas.len());
363 for g in gotchas.gotchas.iter().filter(|_| preview) {
364 let _ = writeln!(out, " - [{}] {}", g.severity, short(&g.trigger, 80));
365 }
366 }
367 if self.not_selected > 0 {
368 let _ = writeln!(out, " Not selected: {} fact(s)", self.not_selected);
369 }
370 for w in &self.withheld {
371 let _ = writeln!(out, " Withheld: {} — {}", w.fact, w.reason);
372 }
373 for name in &self.unmatched {
374 let _ = writeln!(out, " WARNING: fact `{name}` matches no fact");
375 }
376 let redistribution = if m.redistributable == Some(true) {
377 "granted to recipients"
378 } else {
379 "not granted (recipients cannot pass it on)"
380 };
381 let _ = writeln!(out, " Redistribution: {redistribution}");
382 let _ = writeln!(out, " Size: {} bytes", m.integrity.byte_size);
383 if preview {
384 match super::admission::admit_parts(m, c) {
385 Ok(()) => {
386 let _ = writeln!(out, " Admission: would be stored as shown");
387 }
388 Err(refusal) => {
389 let _ = writeln!(out, " Admission: would be refused — {refusal}");
390 }
391 }
392 }
393 out
394 }
395}