1use crate::paths::PathResolver;
15use crate::provider::{to_view, tool_category};
16use crate::types::Session;
17use serde_json::{Map, Value, json};
18use std::collections::HashMap;
19use std::path::{Path as StdPath, PathBuf};
20use toolpath::v1::{
21 ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step,
22 StepIdentity, StructuralChange,
23};
24use toolpath_convo::{ConversationView, Role, Turn};
25
26#[derive(Debug, Clone, Default)]
29pub struct DeriveConfig {
30 pub project_path: Option<String>,
32 pub no_snapshot_diffs: bool,
35}
36
37pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path {
39 let view = to_view(session);
40 derive_path_from_view(session, &view, config, &PathResolver::new())
41}
42
43pub fn derive_path_with_resolver(
46 session: &Session,
47 config: &DeriveConfig,
48 resolver: &PathResolver,
49) -> Path {
50 let view = to_view(session);
51 derive_path_from_view(session, &view, config, resolver)
52}
53
54pub fn derive_project(sessions: &[Session], config: &DeriveConfig) -> Vec<Path> {
56 sessions.iter().map(|s| derive_path(s, config)).collect()
57}
58
59fn derive_path_from_view(
60 session: &Session,
61 view: &ConversationView,
62 config: &DeriveConfig,
63 resolver: &PathResolver,
64) -> Path {
65 let session_short: String = session
66 .id
67 .trim_start_matches("ses_")
68 .chars()
69 .take(8)
70 .collect();
71 let path_id = format!("path-opencode-{}", session_short);
72 let convo_artifact = format!("opencode://{}", session.id);
73
74 let snapshot_repo: Option<git2::Repository> = if config.no_snapshot_diffs {
77 None
78 } else {
79 resolver
80 .snapshot_gitdir(&session.project_id, &session.directory)
81 .ok()
82 .and_then(|gd| git2::Repository::open(gd).ok())
83 };
84
85 let mut steps: Vec<Step> = Vec::with_capacity(view.turns.len());
86 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
87 let mut last_step_id: Option<String> = None;
88 let mut prev_snapshot_after: Option<String> = None;
89 let mut all_files: Vec<String> = Vec::new();
90 let mut files_seen = std::collections::HashSet::<String>::new();
91
92 for (turn_idx, turn) in view.turns.iter().enumerate() {
93 let Some(step) = build_step(
94 turn_idx,
95 turn,
96 &convo_artifact,
97 last_step_id.as_deref(),
98 &mut actors,
99 &snapshot_repo,
100 &mut prev_snapshot_after,
101 &mut all_files,
102 &mut files_seen,
103 ) else {
104 continue;
105 };
106 last_step_id = Some(step.step.id.clone());
107 steps.push(step);
108 }
109
110 let head = last_step_id.unwrap_or_else(|| "empty".to_string());
111
112 let base_uri = config
115 .project_path
116 .clone()
117 .or_else(|| Some(session.directory.to_string_lossy().to_string()))
118 .map(|p| {
119 if p.starts_with('/') {
120 format!("file://{}", p)
121 } else {
122 p
123 }
124 });
125 let base_ref = Some(session.project_id.clone());
128 let base = base_uri.map(|uri| Base {
129 uri,
130 ref_str: base_ref,
131 });
132
133 let mut path_extra: HashMap<String, Value> = HashMap::new();
135 let mut oc: Map<String, Value> = Map::new();
136 oc.insert("session_id".into(), Value::String(session.id.clone()));
137 oc.insert(
138 "project_id".into(),
139 Value::String(session.project_id.clone()),
140 );
141 oc.insert("slug".into(), Value::String(session.slug.clone()));
142 oc.insert("version".into(), Value::String(session.version.clone()));
143 if let Some(total) = view.total_usage.as_ref() {
144 oc.insert(
145 "total_tokens".into(),
146 serde_json::to_value(total).unwrap_or(Value::Null),
147 );
148 }
149 if !all_files.is_empty() {
150 oc.insert(
151 "files_changed".into(),
152 Value::Array(all_files.iter().map(|p| Value::String(p.clone())).collect()),
153 );
154 }
155 path_extra.insert("opencode".into(), Value::Object(oc));
156
157 Path {
158 path: PathIdentity {
159 id: path_id,
160 base,
161 head,
162 graph_ref: None,
163 },
164 steps,
165 meta: Some(PathMeta {
166 title: Some(format!("opencode session: {}", session.title)),
167 source: Some("opencode".to_string()),
168 actors: if actors.is_empty() {
169 None
170 } else {
171 Some(actors)
172 },
173 extra: path_extra,
174 ..Default::default()
175 }),
176 }
177}
178
179#[allow(clippy::too_many_arguments)]
180fn build_step(
181 turn_idx: usize,
182 turn: &Turn,
183 convo_artifact: &str,
184 parent_id: Option<&str>,
185 actors: &mut HashMap<String, ActorDefinition>,
186 snapshot_repo: &Option<git2::Repository>,
187 prev_snapshot_after: &mut Option<String>,
188 all_files: &mut Vec<String>,
189 files_seen: &mut std::collections::HashSet<String>,
190) -> Option<Step> {
191 if turn.text.is_empty() && turn.tool_uses.is_empty() && turn.thinking.is_none() {
193 return None;
194 }
195
196 let (actor, role_str) = resolve_actor(turn, actors);
197
198 let mut convo_extra: HashMap<String, Value> = HashMap::new();
199 convo_extra.insert("role".into(), json!(role_str));
200 if !turn.text.is_empty() {
201 convo_extra.insert("text".into(), json!(turn.text));
202 }
203 if let Some(th) = turn.thinking.as_deref()
204 && !th.is_empty()
205 {
206 convo_extra.insert("thinking".into(), json!(th));
207 }
208 if !turn.tool_uses.is_empty() {
209 let calls: Vec<Value> = turn
210 .tool_uses
211 .iter()
212 .map(|tu| {
213 json!({
214 "name": tu.name,
215 "call_id": tu.id,
216 "category": tu.category,
217 "summary": tool_call_summary(tu),
218 "status": if let Some(r) = tu.result.as_ref() {
219 if r.is_error { "error" } else { "success" }
220 } else { "pending" },
221 })
222 })
223 .collect();
224 convo_extra.insert("tool_calls".into(), Value::Array(calls));
225 }
226 if let Some(u) = turn.token_usage.as_ref() {
227 convo_extra.insert("token_usage".into(), json!(u));
228 }
229 if let Some(sr) = turn.stop_reason.as_deref()
230 && !sr.is_empty()
231 {
232 convo_extra.insert("stop_reason".into(), json!(sr));
233 }
234
235 let convo_change = ArtifactChange {
236 raw: None,
237 structural: Some(StructuralChange {
238 change_type: "conversation.append".to_string(),
239 extra: convo_extra,
240 }),
241 };
242
243 let mut changes: HashMap<String, ArtifactChange> = HashMap::new();
244 changes.insert(convo_artifact.to_string(), convo_change);
245
246 let snapshots = turn
248 .extra
249 .get("opencode")
250 .and_then(|oc| oc.get("snapshots"))
251 .and_then(|v| v.as_array())
252 .map(|arr| {
253 arr.iter()
254 .filter_map(|v| v.as_str().map(str::to_string))
255 .collect::<Vec<_>>()
256 })
257 .unwrap_or_default();
258 let (before, after) = match (snapshots.first(), snapshots.last()) {
259 (Some(first), Some(last)) => {
260 let b = prev_snapshot_after.clone().unwrap_or_else(|| first.clone());
266 (Some(b), Some(last.clone()))
267 }
268 _ => (None, None),
269 };
270
271 if let (Some(b), Some(a), Some(repo)) = (&before, &after, snapshot_repo.as_ref())
274 && b != a
275 {
276 match diff_trees(repo, b, a) {
277 Ok(file_changes) => {
278 for (file_path, artifact_change) in file_changes {
279 if files_seen.insert(file_path.clone()) {
280 all_files.push(file_path.clone());
281 }
282 changes.insert(file_path, artifact_change);
283 }
284 }
285 Err(e) => {
286 eprintln!(
287 "Warning: snapshot diff {}..{} failed: {}",
288 &b[..b.len().min(8)],
289 &a[..a.len().min(8)],
290 e
291 );
292 }
293 }
294 }
295
296 for tu in &turn.tool_uses {
303 let Some(path) = tool_input_file_path(tu) else {
304 continue;
305 };
306 if changes.contains_key(&path) {
307 continue;
308 }
309 if files_seen.insert(path.clone()) {
310 all_files.push(path.clone());
311 }
312 let op = tool_to_operation(&tu.name);
313 let mut extra = HashMap::new();
314 extra.insert("operation".into(), json!(op));
315 extra.insert("tool".into(), json!(tu.name));
316 extra.insert(
317 "source".into(),
318 json!(if snapshot_repo.is_some() {
319 "tool_input_gitignored"
320 } else {
321 "tool_input"
322 }),
323 );
324 changes.insert(
325 path,
326 ArtifactChange {
327 raw: None,
328 structural: Some(StructuralChange {
329 change_type: format!("opencode.{}", op),
330 extra,
331 }),
332 },
333 );
334 }
335
336 if let Some(a) = &after {
338 *prev_snapshot_after = Some(a.clone());
339 }
340
341 let step_id = format!("step-{:04}", turn_idx + 1);
342 let parents = parent_id.map(|p| vec![p.to_string()]).unwrap_or_default();
343
344 Some(Step {
345 step: StepIdentity {
346 id: step_id,
347 parents,
348 actor,
349 timestamp: turn.timestamp.clone(),
350 },
351 change: changes,
352 meta: None,
353 })
354}
355
356fn resolve_actor(
357 turn: &Turn,
358 actors: &mut HashMap<String, ActorDefinition>,
359) -> (String, &'static str) {
360 match &turn.role {
361 Role::User => {
362 actors
363 .entry("human:user".to_string())
364 .or_insert_with(|| ActorDefinition {
365 name: Some("User".to_string()),
366 ..Default::default()
367 });
368 ("human:user".to_string(), "user")
369 }
370 Role::Assistant => {
371 let (key, model_str) = match &turn.model {
372 Some(m) if !m.is_empty() => (format!("agent:{}", m), m.clone()),
373 _ => ("agent:opencode".to_string(), "opencode".to_string()),
374 };
375 let provider = turn
376 .extra
377 .get("opencode")
378 .and_then(|oc| oc.get("providerID"))
379 .and_then(|v| v.as_str())
380 .map(str::to_string);
381 actors
382 .entry(key.clone())
383 .or_insert_with(|| ActorDefinition {
384 name: Some("opencode".to_string()),
385 provider: provider.clone(),
386 model: Some(model_str.clone()),
387 identities: provider
388 .map(|p| {
389 vec![Identity {
390 system: p,
391 id: model_str,
392 }]
393 })
394 .unwrap_or_default(),
395 ..Default::default()
396 });
397 (key, "assistant")
398 }
399 Role::System => {
400 actors
401 .entry("system:opencode".to_string())
402 .or_insert_with(|| ActorDefinition {
403 name: Some("opencode system".to_string()),
404 ..Default::default()
405 });
406 ("system:opencode".to_string(), "system")
407 }
408 Role::Other(s) => {
409 let key = format!("other:{}", s);
410 actors
411 .entry(key.clone())
412 .or_insert_with(|| ActorDefinition {
413 name: Some(s.clone()),
414 ..Default::default()
415 });
416 (key, "other")
417 }
418 }
419}
420
421fn tool_call_summary(tu: &toolpath_convo::ToolInvocation) -> String {
422 let pick = |k: &str| -> Option<String> {
423 tu.input.get(k).and_then(|v| v.as_str()).map(str::to_string)
424 };
425 let s = match tu.name.as_str() {
426 "bash" | "shell" | "exec" => pick("command").or_else(|| pick("cmd")),
427 "read" | "list" | "view" | "ls" => pick("filePath").or_else(|| pick("path")),
428 "write" | "edit" | "multiedit" | "patch" => pick("filePath")
429 .or_else(|| pick("file_path"))
430 .or_else(|| pick("path")),
431 "glob" | "grep" | "search" => pick("pattern").or_else(|| pick("query")),
432 "webfetch" | "fetch" => pick("url"),
433 "websearch" => pick("query"),
434 "task" | "agent" | "spawn_agent" => pick("prompt").or_else(|| pick("task")),
435 _ => None,
436 };
437 s.unwrap_or_default()
438}
439
440fn tool_input_file_path(tu: &toolpath_convo::ToolInvocation) -> Option<String> {
441 tu.input
442 .get("filePath")
443 .or_else(|| tu.input.get("file_path"))
444 .or_else(|| tu.input.get("path"))
445 .and_then(|v| v.as_str())
446 .map(str::to_string)
447}
448
449fn tool_to_operation(name: &str) -> &'static str {
450 match name {
451 "write" => "add",
452 "edit" | "multiedit" | "patch" => "update",
453 "delete" | "rm" => "delete",
454 _ => "touch",
455 }
456}
457
458fn diff_trees(
459 repo: &git2::Repository,
460 before: &str,
461 after: &str,
462) -> Result<Vec<(String, ArtifactChange)>, git2::Error> {
463 let before_obj = repo.revparse_single(before)?;
464 let after_obj = repo.revparse_single(after)?;
465 let before_tree = before_obj.peel_to_tree()?;
466 let after_tree = after_obj.peel_to_tree()?;
467
468 let mut opts = git2::DiffOptions::new();
469 opts.context_lines(3);
470 opts.include_ignored(false);
471 opts.ignore_submodules(true);
472 let diff = repo.diff_tree_to_tree(Some(&before_tree), Some(&after_tree), Some(&mut opts))?;
473
474 let mut by_path: HashMap<PathBuf, (String, &'static str, Option<PathBuf>)> = HashMap::new();
476
477 diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
478 let Some(new_path) = delta.new_file().path() else {
479 if let Some(old) = delta.old_file().path() {
481 let buf = by_path
482 .entry(old.to_path_buf())
483 .or_insert_with(|| (String::new(), "delete", None));
484 append_diff_line(&mut buf.0, line);
485 }
486 return true;
487 };
488 let op = classify_delta(&delta);
489 let entry = by_path.entry(new_path.to_path_buf()).or_insert_with(|| {
490 (
491 String::new(),
492 op,
493 delta.old_file().path().map(|p| p.to_path_buf()),
494 )
495 });
496 append_diff_line(&mut entry.0, line);
497 true
498 })?;
499
500 let mut out: Vec<(String, ArtifactChange)> = Vec::new();
501 for (path, (raw_diff, op, old_path)) in by_path {
502 let file_str = path.to_string_lossy().to_string();
503 let mut extra = HashMap::new();
504 extra.insert("operation".into(), json!(op));
505 if op == "rename"
506 && let Some(old) = &old_path
507 {
508 extra.insert("from".into(), json!(old.to_string_lossy()));
509 }
510 out.push((
511 file_str,
512 ArtifactChange {
513 raw: if raw_diff.is_empty() {
514 None
515 } else {
516 Some(raw_diff)
517 },
518 structural: Some(StructuralChange {
519 change_type: format!("opencode.{}", op),
520 extra,
521 }),
522 },
523 ));
524 }
525 out.sort_by(|a, b| a.0.cmp(&b.0));
527 Ok(out)
528}
529
530fn classify_delta(delta: &git2::DiffDelta) -> &'static str {
531 use git2::Delta;
532 match delta.status() {
533 Delta::Added => "add",
534 Delta::Deleted => "delete",
535 Delta::Modified => "update",
536 Delta::Renamed => "rename",
537 Delta::Copied => "copy",
538 Delta::Typechange => "update",
539 _ => "update",
540 }
541}
542
543fn append_diff_line(buf: &mut String, line: git2::DiffLine<'_>) {
544 use git2::DiffLineType;
545 let prefix = match line.origin_value() {
546 DiffLineType::Context => " ",
547 DiffLineType::Addition => "+",
548 DiffLineType::Deletion => "-",
549 DiffLineType::ContextEOFNL | DiffLineType::AddEOFNL | DiffLineType::DeleteEOFNL => "",
550 _ => "",
551 };
552 buf.push_str(prefix);
553 if let Ok(s) = std::str::from_utf8(line.content()) {
554 buf.push_str(s);
555 }
556}
557
558#[allow(dead_code)]
562fn _use_tool_category(name: &str) -> Option<toolpath_convo::ToolCategory> {
563 tool_category(name)
564}
565
566#[allow(dead_code)]
567fn _use_stdpath(_: &StdPath) {}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572 use crate::OpencodeConvo;
573 use rusqlite::Connection;
574 use std::fs;
575 use tempfile::TempDir;
576 use toolpath::v1::Document;
577
578 fn fixture(body_sql: &str) -> (TempDir, OpencodeConvo, PathResolver) {
579 let temp = TempDir::new().unwrap();
580 let data = temp.path().join(".local/share/opencode");
581 fs::create_dir_all(&data).unwrap();
582 let conn = Connection::open(data.join("opencode.db")).unwrap();
583 conn.execute_batch(&format!(
584 r#"
585 CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text,
586 icon_url text, icon_color text, time_created integer NOT NULL, time_updated integer NOT NULL,
587 time_initialized integer, sandboxes text NOT NULL, commands text);
588 CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, parent_id text,
589 slug text NOT NULL, directory text NOT NULL, title text NOT NULL, version text NOT NULL,
590 share_url text, summary_additions integer, summary_deletions integer, summary_files integer,
591 summary_diffs text, revert text, permission text,
592 time_created integer NOT NULL, time_updated integer NOT NULL,
593 time_compacting integer, time_archived integer, workspace_id text);
594 CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL,
595 time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL);
596 CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL,
597 time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL);
598 {body_sql}
599 "#
600 ))
601 .unwrap();
602 drop(conn);
603 let resolver = PathResolver::new()
604 .with_home(temp.path())
605 .with_data_dir(&data);
606 (
607 temp,
608 OpencodeConvo::with_resolver(resolver.clone()),
609 resolver,
610 )
611 }
612
613 const BASIC_SQL: &str = r#"
614 INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
615 VALUES ('proj_sha', '/tmp/proj', 1000, 3000, '[]');
616 INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
617 VALUES ('ses_abc123', 'proj_sha', 'slug', '/tmp/proj', 'Build pickle', '1.3.10', 1000, 3000);
618 INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES
619 ('m1','ses_abc123',1001,1001,
620 '{"role":"user","time":{"created":1001},"agent":"build","model":{"providerID":"opencode","modelID":"big-pickle"}}'),
621 ('m2','ses_abc123',1002,1100,
622 '{"parentID":"m1","role":"assistant","mode":"build","agent":"build","path":{"cwd":"/tmp/proj","root":"/tmp/proj"},"cost":0.01,"tokens":{"input":10,"output":5,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"claude-sonnet-4-6","providerID":"anthropic","time":{"created":1002,"completed":1100},"finish":"stop"}');
623 INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES
624 ('p1','m1','ses_abc123',1001,1001,'{"type":"text","text":"make a pickle"}'),
625 ('p2','m2','ses_abc123',1002,1002,'{"type":"step-start","snapshot":"snap_a"}'),
626 ('p3','m2','ses_abc123',1005,1005,'{"type":"tool","tool":"write","callID":"c1","state":{"status":"completed","input":{"filePath":"/tmp/proj/main.cpp","content":"int main(){}\n"},"output":"wrote","title":"Write","metadata":{"bytes":13},"time":{"start":1005,"end":1006}}}'),
627 ('p4','m2','ses_abc123',1007,1007,'{"type":"text","text":"done"}'),
628 ('p5','m2','ses_abc123',1010,1010,'{"type":"step-finish","reason":"stop","snapshot":"snap_b","tokens":{"input":10,"output":5,"reasoning":0,"cache":{"read":0,"write":0}},"cost":0.01}');
629 "#;
630
631 #[test]
632 fn derive_basic_shape() {
633 let (_t, mgr, resolver) = fixture(BASIC_SQL);
634 let s = mgr.read_session("ses_abc123").unwrap();
635 let p = derive_path_with_resolver(
636 &s,
637 &DeriveConfig {
638 no_snapshot_diffs: true,
639 ..Default::default()
640 },
641 &resolver,
642 );
643
644 assert!(p.path.id.starts_with("path-opencode-"));
645 assert_eq!(p.path.base.as_ref().unwrap().uri, "file:///tmp/proj");
646 assert_eq!(
647 p.path.base.as_ref().unwrap().ref_str.as_deref(),
648 Some("proj_sha")
649 );
650 assert_eq!(p.steps.len(), 2);
652 assert_eq!(p.path.head, p.steps.last().unwrap().step.id);
654 }
655
656 #[test]
657 fn derive_validates() {
658 let (_t, mgr, resolver) = fixture(BASIC_SQL);
659 let s = mgr.read_session("ses_abc123").unwrap();
660 let p = derive_path_with_resolver(
661 &s,
662 &DeriveConfig {
663 no_snapshot_diffs: true,
664 ..Default::default()
665 },
666 &resolver,
667 );
668 let doc = Document::Path(p);
669 let json = doc.to_json().unwrap();
670 let parsed = Document::from_json(&json).unwrap();
671 if let Document::Path(pp) = parsed {
672 let anc = toolpath::v1::query::ancestors(&pp.steps, &pp.path.head);
673 assert_eq!(anc.len(), pp.steps.len(), "all steps on head ancestry");
674 } else {
675 panic!("expected Path");
676 }
677 }
678
679 #[test]
680 fn derive_actors_populated() {
681 let (_t, mgr, resolver) = fixture(BASIC_SQL);
682 let s = mgr.read_session("ses_abc123").unwrap();
683 let p = derive_path_with_resolver(
684 &s,
685 &DeriveConfig {
686 no_snapshot_diffs: true,
687 ..Default::default()
688 },
689 &resolver,
690 );
691 let actors = p.meta.as_ref().unwrap().actors.as_ref().unwrap();
692 assert!(actors.contains_key("human:user"));
693 assert!(actors.contains_key("agent:claude-sonnet-4-6"));
694 }
695
696 #[test]
697 fn derive_fallback_file_artifact_from_tool() {
698 let (_t, mgr, resolver) = fixture(BASIC_SQL);
699 let s = mgr.read_session("ses_abc123").unwrap();
700 let p = derive_path_with_resolver(
703 &s,
704 &DeriveConfig {
705 no_snapshot_diffs: true,
706 ..Default::default()
707 },
708 &resolver,
709 );
710 let file_step = p
711 .steps
712 .iter()
713 .find(|s| s.change.contains_key("/tmp/proj/main.cpp"))
714 .expect("file artifact missing");
715 let change = &file_step.change["/tmp/proj/main.cpp"];
716 assert!(
717 change.raw.is_none(),
718 "no snapshot repo → no raw perspective"
719 );
720 assert_eq!(
721 change.structural.as_ref().unwrap().change_type,
722 "opencode.add"
723 );
724 }
725
726 #[test]
727 fn derive_uses_snapshot_git_when_available() {
728 let (_t, mgr, resolver) = fixture(BASIC_SQL);
731 let session = mgr.read_session("ses_abc123").unwrap();
732
733 let gitdir = resolver
734 .snapshot_gitdir(&session.project_id, &session.directory)
735 .unwrap();
736 fs::create_dir_all(&gitdir).unwrap();
737 let repo = git2::Repository::init_bare(&gitdir).unwrap();
738
739 let before_tree = {
741 let mut tb = repo.treebuilder(None).unwrap();
742 let blob = repo.blob(b"hello\n").unwrap();
743 tb.insert("README", blob, 0o100644).unwrap();
744 tb.write().unwrap()
745 };
746 let after_tree = {
748 let mut tb = repo.treebuilder(None).unwrap();
749 let readme = repo.blob(b"hello\n").unwrap();
750 tb.insert("README", readme, 0o100644).unwrap();
751 let main = repo.blob(b"int main(){ return 0; }\n").unwrap();
752 tb.insert("main.cpp", main, 0o100644).unwrap();
753 tb.write().unwrap()
754 };
755
756 repo.reference("refs/snapshots/snap_a", before_tree, true, "before")
760 .unwrap();
761 repo.reference("refs/snapshots/snap_b", after_tree, true, "after")
762 .unwrap();
763
764 let conn = rusqlite::Connection::open(resolver.db_path().unwrap()).unwrap();
768 conn.execute(
769 "UPDATE part SET data = REPLACE(data, 'snap_a', ?1) WHERE id = 'p2'",
770 rusqlite::params![before_tree.to_string()],
771 )
772 .unwrap();
773 conn.execute(
774 "UPDATE part SET data = REPLACE(data, 'snap_b', ?1) WHERE id = 'p5'",
775 rusqlite::params![after_tree.to_string()],
776 )
777 .unwrap();
778 drop(conn);
779
780 let session = mgr.read_session("ses_abc123").unwrap();
781 let p = derive_path_with_resolver(&session, &DeriveConfig::default(), &resolver);
782
783 let file_step = p
784 .steps
785 .iter()
786 .find(|s| s.change.contains_key("main.cpp"))
787 .expect("main.cpp artifact missing");
788 let change = &file_step.change["main.cpp"];
789 assert!(
790 change.raw.is_some(),
791 "raw unified diff should be populated from the snapshot repo"
792 );
793 assert!(
794 change
795 .raw
796 .as_ref()
797 .unwrap()
798 .contains("+int main(){ return 0; }"),
799 "diff must include the new content"
800 );
801 assert_eq!(
802 change.structural.as_ref().unwrap().change_type,
803 "opencode.add"
804 );
805 }
806}