1use crate::provider::{file_path_from_args, tool_category};
14use crate::types::{ChatFile, Conversation, GeminiMessage, GeminiRole, ToolCall};
15use serde_json::json;
16use std::collections::HashMap;
17use toolpath::v1::{
18 ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step,
19 StepIdentity, StructuralChange,
20};
21use toolpath_convo::ToolCategory;
22
23#[derive(Debug, Clone, Default)]
25pub struct DeriveConfig {
26 pub project_path: Option<String>,
28 pub include_thinking: bool,
30}
31
32pub fn derive_path(conversation: &Conversation, config: &DeriveConfig) -> Path {
34 let session_short = safe_prefix(&conversation.main.session_id, 8);
35 let path_id = if session_short.is_empty() {
36 format!("path-gemini-{}", safe_prefix(&conversation.session_uuid, 8))
37 } else {
38 format!("path-gemini-{}", session_short)
39 };
40 let convo_artifact = convo_artifact_uri(&conversation.main);
41
42 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
43 let mut steps: Vec<Step> = Vec::new();
44
45 let mut sub_order: Vec<&ChatFile> = conversation.sub_agents.iter().collect();
48 sub_order.sort_by_key(|s| s.start_time);
49 let mut sub_iter = sub_order.into_iter();
50
51 let mut last_step_id: Option<String> = None;
52
53 for msg in &conversation.main.messages {
54 let Some(step) = build_step(
55 msg,
56 &convo_artifact,
57 last_step_id.as_deref(),
58 &mut actors,
59 config,
60 ) else {
61 continue;
62 };
63 let step_id = step.step.id.clone();
64 steps.push(step);
65
66 let delegation_calls: Vec<&ToolCall> = msg
70 .tool_calls()
71 .iter()
72 .filter(|t| tool_category(&t.name) == Some(ToolCategory::Delegation))
73 .collect();
74 for _ in &delegation_calls {
75 if let Some(sub) = sub_iter.next() {
76 append_sub_agent_steps(sub, &step_id, &mut steps, &mut actors, config);
77 }
78 }
79
80 last_step_id = Some(step_id);
81 }
82
83 let leftover: Vec<&ChatFile> = sub_iter.collect();
85 if !leftover.is_empty()
86 && let Some(parent) = last_step_id.clone()
87 {
88 for sub in leftover {
89 append_sub_agent_steps(sub, &parent, &mut steps, &mut actors, config);
90 }
91 }
92
93 let head = last_step_id.unwrap_or_else(|| "empty".to_string());
94
95 let base_uri = config
96 .project_path
97 .clone()
98 .or_else(|| conversation.project_path.clone())
99 .or_else(|| {
100 conversation
101 .main
102 .directories()
103 .first()
104 .map(|p| p.to_string_lossy().to_string())
105 })
106 .map(|p| format!("file://{}", p));
107
108 Path {
109 path: PathIdentity {
110 id: path_id,
111 base: base_uri.map(|uri| Base {
112 uri,
113 ref_str: None,
114 branch: None,
115 }),
116 head,
117 graph_ref: None,
118 },
119 steps,
120 meta: Some(PathMeta {
121 title: Some(format!(
122 "Gemini session: {}",
123 if session_short.is_empty() {
124 safe_prefix(&conversation.session_uuid, 8)
125 } else {
126 session_short
127 }
128 )),
129 source: Some("gemini-cli".to_string()),
130 actors: if actors.is_empty() {
131 None
132 } else {
133 Some(actors)
134 },
135 ..Default::default()
136 }),
137 }
138}
139
140pub fn derive_project(conversations: &[Conversation], config: &DeriveConfig) -> Vec<Path> {
142 conversations
143 .iter()
144 .map(|c| derive_path(c, config))
145 .collect()
146}
147
148fn build_step(
151 msg: &GeminiMessage,
152 convo_artifact: &str,
153 parent_id: Option<&str>,
154 actors: &mut HashMap<String, ActorDefinition>,
155 config: &DeriveConfig,
156) -> Option<Step> {
157 if msg.id.is_empty() {
158 return None;
159 }
160
161 let (actor, role_str) = resolve_actor(msg, actors);
162
163 let mut file_changes: HashMap<String, ArtifactChange> = HashMap::new();
164 let mut text_parts: Vec<String> = Vec::new();
165 let mut tool_calls_meta: Vec<serde_json::Value> = Vec::new();
166
167 let content_text = msg.content.text();
168 if !content_text.trim().is_empty() {
169 text_parts.push(content_text);
170 }
171 if config.include_thinking && !msg.thoughts().is_empty() {
172 for t in msg.thoughts() {
173 let subject = t.subject.as_deref().unwrap_or("");
174 let description = t.description.as_deref().unwrap_or("");
175 let combined = match (subject.is_empty(), description.is_empty()) {
176 (false, false) => format!("[thinking: {}] {}", subject, description),
177 (false, true) => format!("[thinking] {}", subject),
178 (true, false) => format!("[thinking] {}", description),
179 (true, true) => continue,
180 };
181 text_parts.push(combined);
182 }
183 }
184
185 for call in msg.tool_calls() {
186 tool_calls_meta.push(serde_json::json!({
187 "name": call.name,
188 "status": call.status,
189 "summary": tool_call_summary(call),
190 }));
191 if matches!(tool_category(&call.name), Some(ToolCategory::FileWrite))
192 && let Some(fp) = file_path_from_args(&call.args)
193 {
194 let new_change = build_file_write_change(call);
195 file_changes.entry(fp).or_insert(new_change);
199 }
200 }
201
202 if text_parts.is_empty() && tool_calls_meta.is_empty() && file_changes.is_empty() {
203 return None;
204 }
205
206 let mut convo_extra = HashMap::new();
207 convo_extra.insert("role".to_string(), json!(role_str));
208 if !text_parts.is_empty() {
209 let combined = text_parts.join("\n\n");
210 convo_extra.insert("text".to_string(), json!(combined));
211 }
212 if !tool_calls_meta.is_empty() {
213 convo_extra.insert("tool_calls".to_string(), json!(tool_calls_meta));
214 }
215
216 let convo_change = ArtifactChange {
217 raw: None,
218 structural: Some(StructuralChange {
219 change_type: "conversation.append".to_string(),
220 extra: convo_extra,
221 }),
222 };
223
224 let mut changes: HashMap<String, ArtifactChange> = HashMap::new();
225 changes.insert(convo_artifact.to_string(), convo_change);
226 changes.extend(file_changes);
227
228 let step_id = format!("step-{}", safe_prefix(&msg.id, 8));
229 let parents = parent_id.map(|p| vec![p.to_string()]).unwrap_or_default();
230
231 Some(Step {
232 step: StepIdentity {
233 id: step_id,
234 parents,
235 actor,
236 timestamp: msg.timestamp.clone(),
237 },
238 change: changes,
239 meta: None,
240 })
241}
242
243fn build_file_write_change(call: &ToolCall) -> ArtifactChange {
253 let raw = call.file_diff().or_else(|| fallback_raw_diff(call));
254 let structural = Some(StructuralChange {
255 change_type: format!("gemini.{}", call.name),
256 extra: structural_extra_for(call),
257 });
258 ArtifactChange { raw, structural }
259}
260
261fn tool_call_summary(call: &ToolCall) -> String {
265 let pick = |k: &str| -> Option<&str> { call.args.get(k).and_then(|v| v.as_str()) };
266 let summary = match call.name.as_str() {
267 "run_shell_command" => pick("command").map(str::to_string),
268 "read_file" | "read_many_files" | "list_directory" => pick("file_path")
269 .or_else(|| pick("path"))
270 .map(str::to_string),
271 "write_file" | "replace" | "edit" => pick("file_path").map(str::to_string),
272 "glob" => pick("pattern").map(str::to_string),
273 "grep_search" | "search_file_content" => pick("pattern").map(str::to_string),
274 "web_fetch" => pick("url").map(str::to_string),
275 "google_web_search" => pick("query").map(str::to_string),
276 "task" | "activate_skill" => pick("prompt").map(str::to_string),
277 "get_internal_docs" => pick("path").map(str::to_string),
278 _ => None,
279 };
280 summary.unwrap_or_default()
281}
282
283fn structural_extra_for(call: &ToolCall) -> HashMap<String, serde_json::Value> {
284 let mut extra = HashMap::new();
285 match call.name.as_str() {
286 "write_file" => {
287 let content = call
288 .args
289 .get("content")
290 .and_then(|v| v.as_str())
291 .unwrap_or("");
292 extra.insert("operation".into(), json!("write"));
293 extra.insert("byte_count".into(), json!(content.len()));
294 extra.insert("line_count".into(), json!(content.lines().count()));
295 }
296 "replace" => {
297 let old_s = call
298 .args
299 .get("old_string")
300 .and_then(|v| v.as_str())
301 .unwrap_or("");
302 let new_s = call
303 .args
304 .get("new_string")
305 .and_then(|v| v.as_str())
306 .unwrap_or("");
307 let instruction = call
308 .args
309 .get("instruction")
310 .and_then(|v| v.as_str())
311 .unwrap_or("");
312 extra.insert("operation".into(), json!("replace"));
313 extra.insert("old_string".into(), json!(old_s));
314 extra.insert("new_string".into(), json!(new_s));
315 if !instruction.is_empty() {
316 extra.insert("instruction".into(), json!(instruction));
317 }
318 }
319 "edit" => {
320 extra.insert("operation".into(), json!("edit"));
321 }
322 _ => {
323 extra.insert("operation".into(), json!(call.name.clone()));
324 }
325 }
326 extra.insert("status".into(), json!(call.status));
327 extra
328}
329
330fn fallback_raw_diff(call: &ToolCall) -> Option<String> {
334 match call.name.as_str() {
335 "replace" => {
336 let old_s = call.args.get("old_string").and_then(|v| v.as_str())?;
337 let new_s = call.args.get("new_string").and_then(|v| v.as_str())?;
338 let old_lines: Vec<&str> = old_s.split('\n').collect();
339 let new_lines: Vec<&str> = new_s.split('\n').collect();
340 let mut buf = format!("@@ -1,{} +1,{} @@\n", old_lines.len(), new_lines.len());
341 for l in old_lines {
342 buf.push('-');
343 buf.push_str(l);
344 buf.push('\n');
345 }
346 for l in new_lines {
347 buf.push('+');
348 buf.push_str(l);
349 buf.push('\n');
350 }
351 Some(buf)
352 }
353 "write_file" => {
354 let content = call.args.get("content").and_then(|v| v.as_str())?;
355 let lines: Vec<&str> = content.split('\n').collect();
356 let mut buf = format!("@@ -0,0 +1,{} @@\n", lines.len());
357 for l in lines {
358 buf.push('+');
359 buf.push_str(l);
360 buf.push('\n');
361 }
362 Some(buf)
363 }
364 _ => None,
365 }
366}
367
368fn append_sub_agent_steps(
371 sub: &ChatFile,
372 parent_step_id: &str,
373 steps: &mut Vec<Step>,
374 actors: &mut HashMap<String, ActorDefinition>,
375 config: &DeriveConfig,
376) {
377 let convo_artifact = convo_artifact_uri(sub);
378 let mut local_parent = parent_step_id.to_string();
379
380 for msg in &sub.messages {
381 if let Some(mut step) =
382 build_step(msg, &convo_artifact, Some(&local_parent), actors, config)
383 {
384 let session_tag = if sub.session_id.is_empty() {
387 "sub".to_string()
388 } else {
389 safe_prefix(&sub.session_id, 6)
390 };
391 step.step.id = format!("sub-{}-{}", session_tag, safe_prefix(&msg.id, 8));
392 step.step.parents = vec![local_parent.clone()];
393 local_parent = step.step.id.clone();
394 steps.push(step);
395 }
396 }
397}
398
399fn resolve_actor(
400 msg: &GeminiMessage,
401 actors: &mut HashMap<String, ActorDefinition>,
402) -> (String, &'static str) {
403 match &msg.role {
404 GeminiRole::User => {
405 actors
406 .entry("human:user".to_string())
407 .or_insert_with(|| ActorDefinition {
408 name: Some("User".to_string()),
409 ..Default::default()
410 });
411 ("human:user".to_string(), "user")
412 }
413 GeminiRole::Gemini => {
414 let (actor_key, model_str) = match &msg.model {
415 Some(m) if !m.is_empty() => (format!("agent:{}", m), m.clone()),
416 _ => ("agent:gemini-cli".to_string(), "gemini-cli".to_string()),
417 };
418 actors
419 .entry(actor_key.clone())
420 .or_insert_with(|| ActorDefinition {
421 name: Some("Gemini CLI".to_string()),
422 provider: Some("google".to_string()),
423 model: Some(model_str.clone()),
424 identities: vec![Identity {
425 system: "google".to_string(),
426 id: model_str,
427 }],
428 ..Default::default()
429 });
430 (actor_key, "gemini")
431 }
432 GeminiRole::Info => {
433 actors
434 .entry("system:gemini-cli".to_string())
435 .or_insert_with(|| ActorDefinition {
436 name: Some("Gemini CLI system".to_string()),
437 provider: Some("google".to_string()),
438 ..Default::default()
439 });
440 ("system:gemini-cli".to_string(), "info")
441 }
442 GeminiRole::Other(s) => {
443 let key = format!("other:{}", s);
444 actors
445 .entry(key.clone())
446 .or_insert_with(|| ActorDefinition {
447 name: Some(s.clone()),
448 ..Default::default()
449 });
450 (key, "other")
453 }
454 }
455}
456
457fn convo_artifact_uri(chat: &ChatFile) -> String {
458 let sid = if chat.session_id.is_empty() {
459 "unknown".to_string()
460 } else {
461 chat.session_id.clone()
462 };
463 format!("gemini://{}", sid)
464}
465
466fn safe_prefix(s: &str, n: usize) -> String {
467 s.chars().take(n).collect()
468}
469
470#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::types::ChatFile;
476 use serde_json::Value;
477
478 fn parse_chat(s: &str) -> ChatFile {
479 serde_json::from_str(s).unwrap()
480 }
481
482 fn main_only_convo() -> Conversation {
483 let chat = parse_chat(
484 r#"{
485 "sessionId":"sess1",
486 "projectHash":"h",
487 "startTime":"2026-04-17T10:00:00Z",
488 "lastUpdated":"2026-04-17T10:10:00Z",
489 "directories":["/abs/project"],
490 "messages":[
491 {"id":"user-1111aaaa","timestamp":"2026-04-17T10:00:00Z","type":"user","content":[{"text":"Fix the bug"}]},
492 {"id":"ai-2222bbbb","timestamp":"2026-04-17T10:00:01Z","type":"gemini","content":"I'll look.","model":"gemini-3-flash-preview"},
493 {"id":"ai-3333cccc","timestamp":"2026-04-17T10:01:00Z","type":"gemini","content":"Writing fix.","model":"gemini-3-flash-preview","toolCalls":[
494 {"id":"w1","name":"write_file","args":{"file_path":"/abs/project/src/main.rs","content":"fn main(){}"},"status":"success","timestamp":"2026-04-17T10:01:00Z","result":[{"functionResponse":{"id":"w1","name":"write_file","response":{"output":"ok"}}}]}
495 ]}
496 ]
497}"#,
498 );
499 let mut convo = Conversation::new("uuid-1".to_string(), chat);
500 convo.project_path = Some("/abs/project".to_string());
501 convo
502 }
503
504 #[test]
505 fn test_derive_path_basic() {
506 let convo = main_only_convo();
507 let path = derive_path(&convo, &DeriveConfig::default());
508 assert!(path.path.id.starts_with("path-gemini-"));
509 assert_eq!(path.steps.len(), 3);
510 assert_eq!(path.steps[0].step.actor, "human:user");
511 assert!(path.steps[1].step.actor.starts_with("agent:"));
512 }
513
514 #[test]
515 fn test_derive_path_head_is_last_step() {
516 let convo = main_only_convo();
517 let path = derive_path(&convo, &DeriveConfig::default());
518 assert_eq!(path.path.head, path.steps.last().unwrap().step.id);
519 }
520
521 #[test]
522 fn test_derive_path_parents_chain() {
523 let convo = main_only_convo();
524 let path = derive_path(&convo, &DeriveConfig::default());
525 assert!(path.steps[0].step.parents.is_empty());
526 assert_eq!(
527 path.steps[1].step.parents,
528 vec![path.steps[0].step.id.clone()]
529 );
530 assert_eq!(
531 path.steps[2].step.parents,
532 vec![path.steps[1].step.id.clone()]
533 );
534 }
535
536 #[test]
537 fn test_derive_path_conversation_artifact() {
538 let convo = main_only_convo();
539 let path = derive_path(&convo, &DeriveConfig::default());
540 let artifact = "gemini://sess1";
541 assert!(path.steps[0].change.contains_key(artifact));
542 let structural = path.steps[0].change[artifact].structural.as_ref().unwrap();
543 assert_eq!(structural.change_type, "conversation.append");
544 assert_eq!(structural.extra["role"], "user");
545 }
546
547 #[test]
548 fn test_derive_path_file_write_artifact() {
549 let convo = main_only_convo();
550 let path = derive_path(&convo, &DeriveConfig::default());
551 let write_step = &path.steps[2];
552 assert!(write_step.change.contains_key("/abs/project/src/main.rs"));
553 }
554
555 #[test]
556 fn test_derive_path_actors_populated() {
557 let convo = main_only_convo();
558 let path = derive_path(&convo, &DeriveConfig::default());
559 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
560 assert!(actors.contains_key("human:user"));
561 assert!(actors.contains_key("agent:gemini-3-flash-preview"));
562 }
563
564 #[test]
565 fn test_derive_path_base_from_project_path() {
566 let convo = main_only_convo();
567 let path = derive_path(
568 &convo,
569 &DeriveConfig {
570 project_path: Some("/override".to_string()),
571 include_thinking: false,
572 },
573 );
574 assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///override");
575 }
576
577 #[test]
578 fn test_derive_path_base_from_directories_fallback() {
579 let mut convo = main_only_convo();
581 convo.project_path = None;
582 let path = derive_path(&convo, &DeriveConfig::default());
583 assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///abs/project");
584 }
585
586 #[test]
587 fn test_derive_path_no_base_when_unknown() {
588 let mut convo = main_only_convo();
589 convo.project_path = None;
590 convo.main.directories = None;
591 let path = derive_path(&convo, &DeriveConfig::default());
592 assert!(path.path.base.is_none());
593 }
594
595 #[test]
596 fn test_derive_path_skips_empty_messages() {
597 let chat = parse_chat(
598 r#"{
599 "sessionId":"x","projectHash":"","messages":[
600 {"id":"m1","timestamp":"ts","type":"user","content":""},
601 {"id":"m2","timestamp":"ts","type":"user","content":[{"text":" "}]},
602 {"id":"m3","timestamp":"ts","type":"user","content":[{"text":"hello"}]}
603 ]
604}"#,
605 );
606 let convo = Conversation::new("uuid".into(), chat);
607 let path = derive_path(&convo, &DeriveConfig::default());
608 assert_eq!(path.steps.len(), 1);
609 assert_eq!(path.steps[0].step.id, "step-m3");
610 }
611
612 #[test]
613 fn test_derive_path_falls_back_to_gemini_cli_actor() {
614 let chat = parse_chat(
615 r#"{
616 "sessionId":"x","projectHash":"","messages":[
617 {"id":"m1","timestamp":"ts","type":"gemini","content":"hello"}
618 ]
619}"#,
620 );
621 let convo = Conversation::new("uuid".into(), chat);
622 let path = derive_path(&convo, &DeriveConfig::default());
623 assert_eq!(path.steps[0].step.actor, "agent:gemini-cli");
624 }
625
626 #[test]
627 fn test_derive_path_with_replace_tool() {
628 let chat = parse_chat(
629 r#"{
630 "sessionId":"x","projectHash":"","messages":[
631 {"id":"m1","timestamp":"ts","type":"gemini","content":"","toolCalls":[
632 {"id":"r","name":"replace","args":{"file_path":"src/a.rs","oldString":"x","newString":"y"},"status":"success","timestamp":"ts"}
633 ]}
634 ]
635}"#,
636 );
637 let convo = Conversation::new("uuid".into(), chat);
638 let path = derive_path(&convo, &DeriveConfig::default());
639 assert!(path.steps[0].change.contains_key("src/a.rs"));
640 }
641
642 #[test]
643 fn test_derive_path_thinking_included_when_enabled() {
644 let chat = parse_chat(
645 r#"{
646 "sessionId":"x","projectHash":"","messages":[
647 {"id":"m1","timestamp":"ts","type":"gemini","content":"plan","thoughts":[{"subject":"s","description":"deep thought","timestamp":"ts"}]}
648 ]
649}"#,
650 );
651 let convo = Conversation::new("uuid".into(), chat);
652 let path = derive_path(
653 &convo,
654 &DeriveConfig {
655 project_path: None,
656 include_thinking: true,
657 },
658 );
659 let text = path.steps[0].change["gemini://x"]
660 .structural
661 .as_ref()
662 .unwrap()
663 .extra["text"]
664 .as_str()
665 .unwrap();
666 assert!(text.contains("deep thought"));
667 }
668
669 #[test]
670 fn test_derive_path_thinking_omitted_by_default() {
671 let chat = parse_chat(
672 r#"{
673 "sessionId":"x","projectHash":"","messages":[
674 {"id":"m1","timestamp":"ts","type":"gemini","content":"plan","thoughts":[{"subject":"s","description":"deep thought","timestamp":"ts"}]}
675 ]
676}"#,
677 );
678 let convo = Conversation::new("uuid".into(), chat);
679 let path = derive_path(&convo, &DeriveConfig::default());
680 let text = path.steps[0].change["gemini://x"]
681 .structural
682 .as_ref()
683 .unwrap()
684 .extra["text"]
685 .as_str()
686 .unwrap();
687 assert!(!text.contains("deep thought"));
688 assert!(text.contains("plan"));
689 }
690
691 #[test]
692 fn test_derive_path_sub_agent_steps() {
693 let main_chat = parse_chat(
696 r#"{
697 "sessionId":"m","projectHash":"","messages":[
698 {"id":"u1","timestamp":"ts","type":"user","content":[{"text":"go"}]},
699 {"id":"a1","timestamp":"ts","type":"gemini","content":"delegating","model":"gemini-3-flash-preview","toolCalls":[
700 {"id":"t","name":"task","args":{"prompt":"search"},"status":"success","timestamp":"ts"}
701 ]}
702 ]
703}"#,
704 );
705 let sub_chat = parse_chat(
706 r#"{
707 "sessionId":"subby","projectHash":"","kind":"subagent","summary":"found","startTime":"2026-04-17T10:00:00Z","messages":[
708 {"id":"sa","timestamp":"ts","type":"user","content":[{"text":"sub prompt"}]},
709 {"id":"sb","timestamp":"ts","type":"gemini","content":"sub response","model":"gemini-3-flash-preview"}
710 ]
711}"#,
712 );
713 let mut convo = Conversation::new("uuid".into(), main_chat);
714 convo.sub_agents.push(sub_chat);
715
716 let path = derive_path(&convo, &DeriveConfig::default());
717
718 assert_eq!(path.steps.len(), 4);
720 assert!(path.steps[2].step.id.starts_with("sub-"));
722 assert!(path.steps[3].step.id.starts_with("sub-"));
723 assert_eq!(path.steps[2].step.parents, vec!["step-a1".to_string()]);
725 assert_eq!(
727 path.steps[3].step.parents,
728 vec![path.steps[2].step.id.clone()]
729 );
730 assert!(path.steps[2].change.contains_key("gemini://subby"));
732 assert!(path.steps[0].change.contains_key("gemini://m"));
733 }
734
735 #[test]
736 fn test_derive_path_leftover_subagent_attaches_to_last() {
737 let main_chat = parse_chat(
739 r#"{
740 "sessionId":"m","projectHash":"","messages":[
741 {"id":"u1","timestamp":"ts","type":"user","content":[{"text":"go"}]}
742 ]
743}"#,
744 );
745 let sub_chat = parse_chat(
746 r#"{
747 "sessionId":"unlinked","projectHash":"","kind":"subagent","startTime":"2026-04-17T10:00:00Z","messages":[
748 {"id":"sx","timestamp":"ts","type":"user","content":[{"text":"something"}]}
749 ]
750}"#,
751 );
752 let mut convo = Conversation::new("uuid".into(), main_chat);
753 convo.sub_agents.push(sub_chat);
754
755 let path = derive_path(&convo, &DeriveConfig::default());
756 assert_eq!(path.steps.len(), 2);
758 assert!(path.steps[1].step.id.starts_with("sub-"));
759 assert_eq!(path.steps[1].step.parents, vec!["step-u1".to_string()]);
761 }
762
763 #[test]
764 fn test_derive_project_multiple() {
765 let a = main_only_convo();
766 let b = {
767 let mut c = main_only_convo();
768 c.main.session_id = "sess2".into();
769 c.session_uuid = "uuid-2".into();
770 c
771 };
772 let paths = derive_project(&[a, b], &DeriveConfig::default());
773 assert_eq!(paths.len(), 2);
774 assert!(paths[0].path.id.contains("sess1"));
775 assert!(paths[1].path.id.contains("sess2"));
776 }
777
778 #[test]
779 fn test_safe_prefix_behaviour() {
780 assert_eq!(safe_prefix("abc", 8), "abc");
781 assert_eq!(safe_prefix("abcdefghij", 8), "abcdefgh");
782 assert_eq!(safe_prefix("日本語", 2), "日本");
783 }
784
785 #[test]
786 fn test_convo_artifact_uri_unknown_fallback() {
787 let chat = parse_chat(r#"{"sessionId":"","projectHash":"","messages":[]}"#);
788 assert_eq!(convo_artifact_uri(&chat), "gemini://unknown");
789 }
790
791 #[test]
792 fn test_path_id_falls_back_to_session_uuid() {
793 let chat = parse_chat(
794 r#"{"sessionId":"","projectHash":"","messages":[{"id":"m","timestamp":"ts","type":"user","content":[{"text":"hi"}]}]}"#,
795 );
796 let convo = Conversation::new("long-session-uuid-123".into(), chat);
797 let path = derive_path(&convo, &DeriveConfig::default());
798 assert!(path.path.id.starts_with("path-gemini-"));
799 assert!(path.path.id.contains("long-ses"));
801 }
802
803 #[test]
804 fn test_conversation_artifact_extra_fields() {
805 let convo = main_only_convo();
806 let path = derive_path(&convo, &DeriveConfig::default());
807 let structural = path.steps[2].change["gemini://sess1"]
808 .structural
809 .as_ref()
810 .unwrap();
811 assert_eq!(structural.extra["role"], "gemini");
812 let calls = structural.extra["tool_calls"].as_array().unwrap();
813 assert_eq!(calls[0]["name"], Value::String("write_file".to_string()));
814 assert_eq!(calls[0]["summary"], "/abs/project/src/main.rs");
815 }
816
817 #[test]
818 fn test_info_message_becomes_system_step() {
819 let chat = parse_chat(
820 r#"{"sessionId":"s","projectHash":"","messages":[
821 {"id":"u1","timestamp":"ts","type":"user","content":[{"text":"hi"}]},
822 {"id":"i1","timestamp":"ts","type":"info","content":"Request cancelled."}
823]}"#,
824 );
825 let convo = Conversation::new("uuid".into(), chat);
826 let path = derive_path(&convo, &DeriveConfig::default());
827 assert_eq!(path.steps.len(), 2);
828 assert_eq!(path.steps[1].step.actor, "system:gemini-cli");
829 }
830
831 #[test]
832 fn test_file_write_change_has_perspectives() {
833 let chat = parse_chat(
835 r#"{"sessionId":"s","projectHash":"","messages":[
836 {"id":"m1","timestamp":"ts","type":"gemini","content":"","toolCalls":[
837 {"id":"w1","name":"write_file","args":{"file_path":"src/main.rs","content":"fn main() {}\n"},"status":"success","timestamp":"ts"}
838 ]}
839]}"#,
840 );
841 let convo = Conversation::new("uuid".into(), chat);
842 let path = derive_path(&convo, &DeriveConfig::default());
843 let change = &path.steps[0].change["src/main.rs"];
844 assert!(
845 change.raw.is_some() || change.structural.is_some(),
846 "at least one perspective must be populated"
847 );
848 assert!(change.structural.is_some());
849 let structural = change.structural.as_ref().unwrap();
850 assert_eq!(structural.change_type, "gemini.write_file");
851 assert_eq!(structural.extra["operation"], "write");
852 assert_eq!(structural.extra["byte_count"], 13);
853 assert!(change.raw.as_ref().unwrap().contains("+fn main() {}"));
855 }
856
857 #[test]
858 fn test_replace_change_has_diff() {
859 let chat = parse_chat(
860 r#"{"sessionId":"s","projectHash":"","messages":[
861 {"id":"m1","timestamp":"ts","type":"gemini","content":"","toolCalls":[
862 {"id":"r1","name":"replace","args":{"file_path":"src/main.rs","old_string":"hello","new_string":"world","instruction":"swap"},"status":"success","timestamp":"ts"}
863 ]}
864]}"#,
865 );
866 let convo = Conversation::new("uuid".into(), chat);
867 let path = derive_path(&convo, &DeriveConfig::default());
868 let change = &path.steps[0].change["src/main.rs"];
869 let raw = change.raw.as_ref().unwrap();
870 assert!(raw.contains("-hello"));
871 assert!(raw.contains("+world"));
872 let structural = change.structural.as_ref().unwrap();
873 assert_eq!(structural.extra["operation"], "replace");
874 assert_eq!(structural.extra["instruction"], "swap");
875 }
876
877 #[test]
878 fn test_file_diff_preferred_over_fallback() {
879 let chat = parse_chat(
882 r#"{"sessionId":"s","projectHash":"","messages":[
883 {"id":"m1","timestamp":"ts","type":"gemini","content":"","toolCalls":[
884 {"id":"r1","name":"replace","args":{"file_path":"a.rs","old_string":"x","new_string":"y"},"status":"success","timestamp":"ts","resultDisplay":{"fileDiff":"Index: a.rs\n...GEMINI DIFF..."}}
885 ]}
886]}"#,
887 );
888 let convo = Conversation::new("uuid".into(), chat);
889 let path = derive_path(&convo, &DeriveConfig::default());
890 let raw = path.steps[0].change["a.rs"].raw.as_ref().unwrap();
891 assert!(raw.contains("GEMINI DIFF"));
892 }
893
894 #[test]
895 fn test_tool_call_summary_preserves_shell_command() {
896 let chat = parse_chat(
897 r#"{"sessionId":"s","projectHash":"","messages":[
898 {"id":"m1","timestamp":"ts","type":"gemini","content":"building","toolCalls":[
899 {"id":"s1","name":"run_shell_command","args":{"command":"cargo build --release"},"status":"success","timestamp":"ts"}
900 ]}
901]}"#,
902 );
903 let convo = Conversation::new("uuid".into(), chat);
904 let path = derive_path(&convo, &DeriveConfig::default());
905 let structural = path.steps[0].change["gemini://s"]
906 .structural
907 .as_ref()
908 .unwrap();
909 let calls = structural.extra["tool_calls"].as_array().unwrap();
910 assert_eq!(calls[0]["summary"], "cargo build --release");
911 }
912}