Skip to main content

lean_ctx/core/context_package/
selection.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! V01 — what `pack create` and `ctx_pack create` pack: a selection of
4//! layers and facts, and a preview that shows exactly that without writing.
5//!
6//! One build serves both: the preview renders the package the create would
7//! store, so the two cannot drift. Facts merged from a received package go
8//! in only when that package grants redistribution (V02); private session
9//! text never goes in (the session layer carries the journal only, and the
10//! admission of every install refuses what the policy withholds).
11
12use 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
20/// Layers built when the caller names none.
21pub(crate) const DEFAULT_LAYERS: &[&str] = &["knowledge", "graph", "session", "gotchas"];
22/// Layers a caller may name.
23pub(crate) const SELECTABLE_LAYERS: &[&str] =
24    &["knowledge", "graph", "session", "patterns", "gotchas"];
25
26/// The caller's choice. Empty lists mean "no restriction"; facts pass when
27/// they match every category/key restriction given, or are named in `facts`.
28#[derive(Debug, Clone, Default)]
29pub struct PackSelection {
30    pub layers: Vec<String>,
31    pub categories: Vec<String>,
32    pub keys: Vec<String>,
33    /// Explicit facts as `category/key`.
34    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/// Everything about the package besides its content.
86#[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/// A fact the selection left out for a reason the caller must see.
101#[derive(Debug, Clone)]
102pub struct Withheld {
103    pub fact: String,
104    pub reason: String,
105}
106
107/// The package a create would store, plus what it left out.
108pub struct PackPlan {
109    pub manifest: PackageManifest,
110    pub content: PackageContent,
111    /// Facts merged from a package that grants no redistribution.
112    pub withheld: Vec<Withheld>,
113    /// Facts the selection did not take (count only).
114    pub not_selected: usize,
115    /// `--fact` names that match no fact.
116    pub unmatched: Vec<String>,
117}
118
119/// Build the selected package in memory. Nothing is written.
120pub 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    // V02: facts merged from a package keep its rights.
129    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    // V02: no project-root hash in the manifest — it is an unsalted hash of
205    // a local path, and a package leaves this machine.
206    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    /// What the package holds: counts, fact status, what was left out. The
272    /// preview also lists every item (title, status, source, validity) and
273    /// whether the install's admission would take the package as built.
274    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}