Skip to main content

lean_ctx/core/
handoff_transfer_bundle.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5use crate::core::handoff_ledger::HandoffLedgerV1;
6
7const MAX_BUNDLE_BYTES: usize = 350_000;
8const MAX_PROOF_FILES: usize = 50;
9const MAX_ARTIFACT_ITEMS: usize = 80;
10const MAX_LEDGER_SNAPSHOT_CHARS: usize = 80_000;
11const MAX_CURATED_REF_CHARS: usize = 20_000;
12const MAX_DECISION_CHARS: usize = 2_000;
13const MAX_FINDING_CHARS: usize = 2_000;
14const MAX_NEXT_STEP_CHARS: usize = 1_000;
15const MAX_TASK_CHARS: usize = 4_000;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BundlePrivacyV1 {
19    Redacted,
20    Full,
21}
22
23impl BundlePrivacyV1 {
24    pub fn parse(s: Option<&str>) -> Self {
25        match s.unwrap_or("redacted").trim().to_lowercase().as_str() {
26            "full" => Self::Full,
27            _ => Self::Redacted,
28        }
29    }
30
31    pub fn as_str(&self) -> &'static str {
32        match self {
33            Self::Redacted => "redacted",
34            Self::Full => "full",
35        }
36    }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct HandoffTransferBundleV1 {
41    pub schema_version: u32,
42    pub exported_at: DateTime<Utc>,
43    pub privacy: String,
44    pub project: ProjectIdentityV1,
45    pub ledger: HandoffLedgerV1,
46    pub artifacts: ArtifactsExcerptV1,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ProjectIdentityV1 {
51    pub project_root_hash: Option<String>,
52    pub project_identity_hash: Option<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct ArtifactsExcerptV1 {
57    pub resolved: Vec<crate::core::artifacts::ResolvedArtifact>,
58    pub proof_files: Vec<ProofFileV1>,
59    pub warnings: Vec<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ProofFileV1 {
64    pub name: String,
65    pub md5: String,
66    pub bytes: u64,
67}
68
69pub fn build_bundle_v1(
70    mut ledger: HandoffLedgerV1,
71    project_root: Option<&str>,
72    privacy: BundlePrivacyV1,
73) -> HandoffTransferBundleV1 {
74    let role_name = crate::core::roles::active_role_name();
75    let effective_privacy = match privacy {
76        BundlePrivacyV1::Full
77            if role_name == "admin"
78                && !crate::core::redaction::redaction_enabled_for_active_role() =>
79        {
80            BundlePrivacyV1::Full
81        }
82        _ => BundlePrivacyV1::Redacted,
83    };
84
85    let (project_root_hash, project_identity_hash) = project_root.map_or((None, None), |root| {
86        let root_hash = crate::core::project_hash::hash_project_root(root);
87        let identity = crate::core::project_hash::project_identity(root);
88        let identity_hash = identity.as_deref().map(crate::core::hasher::hash_str);
89        (Some(root_hash), identity_hash)
90    });
91
92    cap_ledger_in_place(&mut ledger);
93
94    match effective_privacy {
95        BundlePrivacyV1::Full => {}
96        BundlePrivacyV1::Redacted => {
97            redact_ledger_in_place(&mut ledger);
98        }
99    }
100
101    // Keep embedded ledger internally consistent.
102    ledger.content_md5 = crate::core::handoff_ledger::compute_content_md5_for_ledger(&ledger);
103
104    let artifacts = project_root
105        .map(Path::new)
106        .map(build_artifacts_excerpt_v1)
107        .unwrap_or_default();
108
109    HandoffTransferBundleV1 {
110        schema_version: crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION,
111        exported_at: Utc::now(),
112        privacy: effective_privacy.as_str().to_string(),
113        project: ProjectIdentityV1 {
114            project_root_hash,
115            project_identity_hash,
116        },
117        ledger,
118        artifacts,
119    }
120}
121
122pub fn serialize_bundle_v1_pretty(bundle: &HandoffTransferBundleV1) -> Result<String, String> {
123    let json = serde_json::to_string_pretty(bundle).map_err(|e| e.to_string())?;
124    if json.len() > MAX_BUNDLE_BYTES {
125        return Err(format!(
126            "ERROR: bundle too large ({} bytes > max {}). Use privacy=redacted and/or reduce curated refs.",
127            json.len(),
128            MAX_BUNDLE_BYTES
129        ));
130    }
131    Ok(json)
132}
133
134pub fn parse_bundle_v1(json: &str) -> Result<HandoffTransferBundleV1, String> {
135    let b: HandoffTransferBundleV1 = serde_json::from_str(json).map_err(|e| e.to_string())?;
136    if b.schema_version != crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION {
137        return Err(format!(
138            "ERROR: unsupported schema_version {} (expected {})",
139            b.schema_version,
140            crate::core::contracts::HANDOFF_TRANSFER_BUNDLE_V1_SCHEMA_VERSION
141        ));
142    }
143    Ok(b)
144}
145
146pub fn write_bundle_v1(path: &Path, json: &str) -> Result<(), String> {
147    let parent = path
148        .parent()
149        .ok_or_else(|| "ERROR: invalid path".to_string())?;
150    if !parent.exists() {
151        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
152    }
153    let tmp = parent.join(format!(
154        ".{}.tmp",
155        path.file_name()
156            .and_then(|s| s.to_str())
157            .unwrap_or("bundle")
158    ));
159    std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
160    std::fs::rename(&tmp, path).map_err(|e| e.to_string())?;
161    Ok(())
162}
163
164pub fn read_bundle_v1(path: &Path) -> Result<HandoffTransferBundleV1, String> {
165    let json = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
166    if json.len() > MAX_BUNDLE_BYTES {
167        return Err(format!(
168            "ERROR: bundle file too large ({} bytes > max {})",
169            json.len(),
170            MAX_BUNDLE_BYTES
171        ));
172    }
173    parse_bundle_v1(&json)
174}
175
176pub fn project_identity_warning(
177    bundle: &HandoffTransferBundleV1,
178    project_root: &str,
179) -> Option<String> {
180    let current_root_hash = crate::core::project_hash::hash_project_root(project_root);
181    let current_identity_hash = crate::core::project_hash::project_identity(project_root)
182        .as_deref()
183        .map(crate::core::hasher::hash_str);
184
185    if let Some(ref exported) = bundle.project.project_root_hash {
186        if exported != &current_root_hash {
187            return Some(
188                "WARNING: project_root_hash mismatch (importing into different project root)."
189                    .to_string(),
190            );
191        }
192    }
193
194    if let (Some(exported), Some(current)) = (
195        bundle.project.project_identity_hash.as_ref(),
196        current_identity_hash.as_ref(),
197    ) {
198        if exported != current {
199            return Some(
200                "WARNING: project_identity_hash mismatch (importing into different project identity)."
201                    .to_string(),
202            );
203        }
204    }
205
206    None
207}
208
209fn build_artifacts_excerpt_v1(project_root: &Path) -> ArtifactsExcerptV1 {
210    let mut out = ArtifactsExcerptV1::default();
211
212    let resolved = crate::core::artifacts::load_resolved(project_root);
213    out.warnings.extend(resolved.warnings);
214    out.resolved = resolved
215        .artifacts
216        .into_iter()
217        .take(MAX_ARTIFACT_ITEMS)
218        .collect();
219
220    let proofs_dir = project_root.join(".lean-ctx").join("proofs");
221    if let Ok(rd) = std::fs::read_dir(&proofs_dir) {
222        let mut files = Vec::new();
223        for e in rd.flatten() {
224            let p = e.path();
225            if !p.is_file() {
226                continue;
227            }
228            let name = p
229                .file_name()
230                .map(|n| n.to_string_lossy().to_string())
231                .unwrap_or_default();
232            if name.is_empty() {
233                continue;
234            }
235            let bytes = p.metadata().map_or(0, |m| m.len());
236            let md5 = match std::fs::read(&p) {
237                Ok(b) => crate::core::hasher::hash_hex(&b),
238                Err(e) => {
239                    out.warnings
240                        .push(format!("proof read failed: {} ({e})", p.display()));
241                    continue;
242                }
243            };
244            files.push(ProofFileV1 { name, md5, bytes });
245        }
246        files.sort_by(|a, b| a.name.cmp(&b.name));
247        out.proof_files = files.into_iter().take(MAX_PROOF_FILES).collect();
248    }
249
250    out
251}
252
253fn cap_ledger_in_place(ledger: &mut HandoffLedgerV1) {
254    if ledger.session_snapshot.len() > MAX_LEDGER_SNAPSHOT_CHARS {
255        ledger.session_snapshot =
256            truncate_chars(&ledger.session_snapshot, MAX_LEDGER_SNAPSHOT_CHARS);
257    }
258
259    if let Some(ref mut task) = ledger.session.task {
260        *task = truncate_chars(task, MAX_TASK_CHARS);
261    }
262
263    for d in &mut ledger.session.decisions {
264        *d = truncate_chars(d, MAX_DECISION_CHARS);
265    }
266    for f in &mut ledger.session.findings {
267        *f = truncate_chars(f, MAX_FINDING_CHARS);
268    }
269    for s in &mut ledger.session.next_steps {
270        *s = truncate_chars(s, MAX_NEXT_STEP_CHARS);
271    }
272
273    for r in &mut ledger.curated_refs {
274        if r.content.len() > MAX_CURATED_REF_CHARS {
275            r.content = truncate_chars(&r.content, MAX_CURATED_REF_CHARS);
276        }
277    }
278}
279
280fn redact_ledger_in_place(ledger: &mut HandoffLedgerV1) {
281    ledger.project_root = None;
282    ledger.session_snapshot.clear();
283
284    if let Some(ref mut task) = ledger.session.task {
285        *task = crate::core::redaction::redact_text(task);
286    }
287    for d in &mut ledger.session.decisions {
288        *d = crate::core::redaction::redact_text(d);
289    }
290    for f in &mut ledger.session.findings {
291        *f = crate::core::redaction::redact_text(f);
292    }
293    for s in &mut ledger.session.next_steps {
294        *s = crate::core::redaction::redact_text(s);
295    }
296
297    for fact in &mut ledger.knowledge.facts {
298        fact.value = crate::core::redaction::redact_text(&fact.value);
299    }
300
301    for r in &mut ledger.curated_refs {
302        r.content = crate::core::redaction::redact_text(&r.content);
303    }
304}
305
306fn truncate_chars(s: &str, max: usize) -> String {
307    if s.chars().count() <= max {
308        return s.to_string();
309    }
310    s.chars().take(max).collect::<String>()
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    fn sample_ledger() -> HandoffLedgerV1 {
318        HandoffLedgerV1 {
319            schema_version: crate::core::contracts::HANDOFF_LEDGER_V1_SCHEMA_VERSION,
320            created_at: "20260503T000000Z".to_string(),
321            content_md5: "old".to_string(),
322            manifest_md5: "m".to_string(),
323            project_root: Some("/abs/project".to_string()),
324            agent_id: Some("a".to_string()),
325            client_name: Some("cursor".to_string()),
326            workflow: None,
327            session_snapshot: "snapshot".to_string(),
328            session: crate::core::handoff_ledger::SessionExcerpt {
329                id: "s".to_string(),
330                task: Some("task".to_string()),
331                decisions: vec!["d1".to_string()],
332                findings: vec!["f1".to_string()],
333                next_steps: vec!["n1".to_string()],
334            },
335            tool_calls: crate::core::handoff_ledger::ToolCallsSummary::default(),
336            evidence_keys: vec!["tool:ctx_read".to_string()],
337            knowledge: crate::core::handoff_ledger::KnowledgeExcerpt {
338                project_hash: None,
339                facts: vec![crate::core::handoff_ledger::KnowledgeFactMini {
340                    category: "c".to_string(),
341                    key: "k".to_string(),
342                    value: "secret=abcdef0123456789abcdef0123456789".to_string(),
343                    confidence: 0.9,
344                }],
345            },
346            curated_refs: vec![crate::core::handoff_ledger::CuratedRef {
347                path: "src/lib.rs".to_string(),
348                mode: "signatures".to_string(),
349                content_md5: "x".to_string(),
350                content: "fn a() {}".to_string(),
351            }],
352            active_overlays: Vec::new(),
353        }
354    }
355
356    #[test]
357    fn redacted_bundle_removes_sensitive_fields() {
358        let ledger = sample_ledger();
359        let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
360        assert_eq!(b.privacy, "redacted");
361        assert!(b.ledger.project_root.is_none());
362        assert!(b.ledger.session_snapshot.is_empty());
363    }
364
365    #[test]
366    fn serialize_parse_roundtrip() {
367        let ledger = sample_ledger();
368        let b = build_bundle_v1(ledger, None, BundlePrivacyV1::Redacted);
369        let json = serialize_bundle_v1_pretty(&b).expect("json");
370        assert!(json.len() < MAX_BUNDLE_BYTES);
371        let parsed = parse_bundle_v1(&json).expect("parse");
372        assert_eq!(parsed.schema_version, b.schema_version);
373        assert_eq!(parsed.privacy, "redacted");
374    }
375}