1use crate::provider::to_view;
15use crate::types::{PatchChange, Session};
16use serde_json::{Map, Value, json};
17use std::collections::HashMap;
18use toolpath::v1::{
19 ActorDefinition, ArtifactChange, Base, Identity, Path, PathIdentity, PathMeta, Step,
20 StepIdentity, StructuralChange,
21};
22use toolpath_convo::{ConversationView, Role, Turn};
23
24#[derive(Debug, Clone, Default)]
34pub struct DeriveConfig {
35 pub project_path: Option<String>,
37}
38
39pub fn derive_path(session: &Session, config: &DeriveConfig) -> Path {
41 let view = to_view(session);
42 derive_path_from_view(session, &view, config)
43}
44
45pub fn derive_project(sessions: &[Session], config: &DeriveConfig) -> Vec<Path> {
47 sessions.iter().map(|s| derive_path(s, config)).collect()
48}
49
50fn derive_path_from_view(
53 session: &Session,
54 view: &ConversationView,
55 config: &DeriveConfig,
56) -> Path {
57 let meta = session.meta();
58 let session_short: String = session.id.chars().take(8).collect();
59 let path_id = format!("path-codex-{}", session_short);
60 let convo_artifact = format!("codex://{}", session.id);
61
62 let mut steps: Vec<Step> = Vec::with_capacity(view.turns.len());
63 let mut actors: HashMap<String, ActorDefinition> = HashMap::new();
64 let mut last_step_id: Option<String> = None;
65
66 for (turn_idx, turn) in view.turns.iter().enumerate() {
67 let Some(step) = build_step(
68 turn_idx,
69 turn,
70 &convo_artifact,
71 last_step_id.as_deref(),
72 &mut actors,
73 ) else {
74 continue;
75 };
76 last_step_id = Some(step.step.id.clone());
77 steps.push(step);
78 }
79
80 let head = last_step_id.unwrap_or_else(|| "empty".to_string());
81
82 let base_uri = config
85 .project_path
86 .clone()
87 .or_else(|| meta.as_ref().map(|m| m.cwd.to_string_lossy().to_string()))
88 .or_else(|| {
89 view.turns
90 .first()
91 .and_then(|t| t.environment.as_ref()?.working_dir.clone())
92 })
93 .map(|p| {
94 if p.starts_with('/') {
95 format!("file://{}", p)
96 } else {
97 p
98 }
99 });
100
101 let base_ref = meta
103 .as_ref()
104 .and_then(|m| m.git.as_ref().and_then(|g| g.commit_hash.clone()));
105
106 let base = base_uri.map(|uri| Base {
107 uri,
108 ref_str: base_ref,
109 });
110
111 let mut path_extra: HashMap<String, Value> = HashMap::new();
115 let mut codex_meta: Map<String, Value> = Map::new();
116 if let Some(m) = meta.as_ref() {
117 codex_meta.insert("session_id".into(), Value::String(session.id.clone()));
118 codex_meta.insert("originator".into(), Value::String(m.originator.clone()));
119 codex_meta.insert("cli_version".into(), Value::String(m.cli_version.clone()));
120 codex_meta.insert("source".into(), Value::String(m.source.clone()));
121 if let Some(model_provider) = &m.model_provider {
122 codex_meta.insert(
123 "model_provider".into(),
124 Value::String(model_provider.clone()),
125 );
126 }
127 if let Some(forked) = &m.forked_from_id {
128 codex_meta.insert("forked_from_id".into(), Value::String(forked.clone()));
129 }
130 if let Some(git) = &m.git {
131 let mut g: Map<String, Value> = Map::new();
132 if let Some(v) = &git.commit_hash {
133 g.insert("commit_hash".into(), Value::String(v.clone()));
134 }
135 if let Some(v) = &git.branch {
136 g.insert("branch".into(), Value::String(v.clone()));
137 }
138 if let Some(v) = &git.repository_url {
139 g.insert("repository_url".into(), Value::String(v.clone()));
140 }
141 if !g.is_empty() {
142 codex_meta.insert("git".into(), Value::Object(g));
143 }
144 }
145 }
146 if !view.files_changed.is_empty() {
147 codex_meta.insert(
148 "files_changed".into(),
149 Value::Array(
150 view.files_changed
151 .iter()
152 .map(|p| Value::String(p.clone()))
153 .collect(),
154 ),
155 );
156 }
157 if !codex_meta.is_empty() {
158 path_extra.insert("codex".into(), Value::Object(codex_meta));
159 }
160
161 Path {
162 path: PathIdentity {
163 id: path_id,
164 base,
165 head,
166 graph_ref: None,
167 },
168 steps,
169 meta: Some(PathMeta {
170 title: Some(format!("Codex session: {}", session_short)),
171 source: Some("codex".to_string()),
172 actors: if actors.is_empty() {
173 None
174 } else {
175 Some(actors)
176 },
177 extra: path_extra,
178 ..Default::default()
179 }),
180 }
181}
182
183fn build_step(
184 turn_idx: usize,
185 turn: &Turn,
186 convo_artifact: &str,
187 parent_id: Option<&str>,
188 actors: &mut HashMap<String, ActorDefinition>,
189) -> Option<Step> {
190 if turn.text.is_empty()
193 && turn.tool_uses.is_empty()
194 && turn.thinking.is_none()
195 && extract_patch_changes(turn).is_empty()
196 {
197 return None;
198 }
199
200 let (actor, role_str) = resolve_actor(turn, actors);
201
202 let mut convo_extra: HashMap<String, Value> = HashMap::new();
204 convo_extra.insert("role".into(), json!(role_str));
205 if !turn.text.is_empty() {
206 convo_extra.insert("text".into(), json!(turn.text));
207 }
208 if let Some(th) = turn.thinking.as_deref()
211 && !th.is_empty()
212 {
213 convo_extra.insert("thinking".into(), json!(th));
214 }
215 if !turn.tool_uses.is_empty() {
216 let calls: Vec<Value> = turn
217 .tool_uses
218 .iter()
219 .map(|tu| {
220 json!({
221 "name": tu.name,
222 "call_id": tu.id,
223 "category": tu.category,
224 "summary": tool_call_summary(tu),
225 "status": tool_call_status(turn, &tu.id),
226 })
227 })
228 .collect();
229 convo_extra.insert("tool_calls".into(), Value::Array(calls));
230 }
231 if let Some(u) = turn.token_usage.as_ref() {
232 convo_extra.insert("token_usage".into(), json!(u));
233 }
234 if let Some(ph) = turn
235 .extra
236 .get("codex")
237 .and_then(|c| c.get("phase"))
238 .and_then(|v| v.as_str())
239 {
240 convo_extra.insert("phase".into(), json!(ph));
241 }
242
243 let convo_change = ArtifactChange {
244 raw: None,
245 structural: Some(StructuralChange {
246 change_type: "conversation.append".to_string(),
247 extra: convo_extra,
248 }),
249 };
250
251 let mut changes: HashMap<String, ArtifactChange> = HashMap::new();
252 changes.insert(convo_artifact.to_string(), convo_change);
253
254 for (path, patch) in extract_patch_changes(turn) {
256 changes.insert(path, patch);
257 }
258
259 let step_id = format!("step-{:04}", turn_idx + 1);
260 let parents = parent_id.map(|p| vec![p.to_string()]).unwrap_or_default();
261
262 Some(Step {
263 step: StepIdentity {
264 id: step_id,
265 parents,
266 actor,
267 timestamp: turn.timestamp.clone(),
268 },
269 change: changes,
270 meta: None,
271 })
272}
273
274fn resolve_actor(
275 turn: &Turn,
276 actors: &mut HashMap<String, ActorDefinition>,
277) -> (String, &'static str) {
278 match &turn.role {
279 Role::User => {
280 actors
281 .entry("human:user".to_string())
282 .or_insert_with(|| ActorDefinition {
283 name: Some("User".to_string()),
284 ..Default::default()
285 });
286 ("human:user".to_string(), "user")
287 }
288 Role::Assistant => {
289 let (actor_key, model_str) = match &turn.model {
290 Some(m) if !m.is_empty() => (format!("agent:{}", m), m.clone()),
291 _ => ("agent:codex".to_string(), "codex".to_string()),
292 };
293 actors
294 .entry(actor_key.clone())
295 .or_insert_with(|| ActorDefinition {
296 name: Some("Codex CLI".to_string()),
297 provider: Some("openai".to_string()),
298 model: Some(model_str.clone()),
299 identities: vec![Identity {
300 system: "openai".to_string(),
301 id: model_str,
302 }],
303 ..Default::default()
304 });
305 (actor_key, "assistant")
306 }
307 Role::System => {
308 actors
309 .entry("system:codex".to_string())
310 .or_insert_with(|| ActorDefinition {
311 name: Some("Codex CLI system".to_string()),
312 provider: Some("openai".to_string()),
313 ..Default::default()
314 });
315 ("system:codex".to_string(), "developer")
316 }
317 Role::Other(s) => {
318 let key = format!("other:{}", s);
319 actors
320 .entry(key.clone())
321 .or_insert_with(|| ActorDefinition {
322 name: Some(s.clone()),
323 ..Default::default()
324 });
325 (key, "other")
326 }
327 }
328}
329
330fn tool_call_status(turn: &Turn, call_id: &str) -> String {
331 turn.extra
332 .get("codex")
333 .and_then(|c| c.get("tool_extras"))
334 .and_then(|t| t.get(call_id))
335 .and_then(|te| te.get("status").or_else(|| te.get("exit_code")))
336 .and_then(|v| {
337 v.as_str()
338 .map(str::to_string)
339 .or_else(|| v.as_i64().map(|n| n.to_string()))
340 })
341 .unwrap_or_else(|| {
342 turn.tool_uses
343 .iter()
344 .find(|tu| tu.id == call_id)
345 .and_then(|tu| tu.result.as_ref())
346 .map(|r| {
347 if r.is_error {
348 "error".to_string()
349 } else {
350 "success".to_string()
351 }
352 })
353 .unwrap_or_default()
354 })
355}
356
357fn tool_call_summary(tu: &toolpath_convo::ToolInvocation) -> String {
359 let pick = |k: &str| -> Option<String> {
360 tu.input.get(k).and_then(|v| v.as_str()).map(str::to_string)
361 };
362 let summary = match tu.name.as_str() {
363 "exec_command" | "shell" | "unified_exec" => pick("cmd").or_else(|| pick("command")),
364 "write_stdin" => pick("chars").or_else(|| pick("session_id")),
365 "read_file" | "read_many_files" | "list_dir" | "view_image" => pick("path"),
366 "write_file" | "replace" | "edit" => pick("file_path"),
367 "apply_patch" => {
368 tu.input.as_str().and_then(|s| {
370 s.lines()
371 .find(|l| {
372 l.starts_with("*** Add File:")
373 || l.starts_with("*** Update File:")
374 || l.starts_with("*** Delete File:")
375 })
376 .map(str::to_string)
377 })
378 }
379 "glob" | "grep_search" | "search_file_content" => pick("pattern").or_else(|| pick("query")),
380 "web_fetch" => pick("url"),
381 "web_search" | "google_web_search" => pick("query"),
382 "spawn_agent" | "task" | "activate_skill" => pick("prompt").or_else(|| pick("task")),
383 _ => None,
384 };
385 summary.unwrap_or_default()
386}
387
388fn extract_patch_changes(turn: &Turn) -> Vec<(String, ArtifactChange)> {
391 let Some(codex) = turn.extra.get("codex") else {
392 return Vec::new();
393 };
394 let Some(Value::Array(patches)) = codex.get("patch_changes") else {
395 return Vec::new();
396 };
397
398 let mut out: Vec<(String, ArtifactChange)> = Vec::new();
399 for patch in patches {
400 let Some(Value::Object(changes)) = patch.get("changes") else {
401 continue;
402 };
403 for (path, change_val) in changes {
404 let Some(change) = parse_patch_change(change_val) else {
405 continue;
406 };
407 let (raw, structural) = patch_change_to_perspectives(&change, path);
408 out.push((
409 path.clone(),
410 ArtifactChange {
411 raw,
412 structural: Some(structural),
413 },
414 ));
415 }
416 }
417 out
418}
419
420fn parse_patch_change(v: &Value) -> Option<PatchChange> {
421 serde_json::from_value::<PatchChange>(v.clone()).ok()
422}
423
424fn patch_change_to_perspectives(
425 change: &PatchChange,
426 file_path: &str,
427) -> (Option<String>, StructuralChange) {
428 let mut extra: HashMap<String, Value> = HashMap::new();
429 match change {
430 PatchChange::Add { content, .. } => {
431 extra.insert("operation".into(), json!("add"));
432 extra.insert("byte_count".into(), json!(content.len()));
433 extra.insert("line_count".into(), json!(content.lines().count()));
434 let raw = synth_add_diff(file_path, content);
435 (
436 Some(raw),
437 StructuralChange {
438 change_type: "codex.add".into(),
439 extra,
440 },
441 )
442 }
443 PatchChange::Update {
444 unified_diff,
445 move_path,
446 ..
447 } => {
448 extra.insert("operation".into(), json!("update"));
449 if let Some(mp) = move_path {
450 extra.insert("move_path".into(), json!(mp));
451 }
452 (
453 Some(unified_diff.clone()),
454 StructuralChange {
455 change_type: "codex.update".into(),
456 extra,
457 },
458 )
459 }
460 PatchChange::Delete {
461 original_content, ..
462 } => {
463 extra.insert("operation".into(), json!("delete"));
464 let raw = original_content
465 .as_ref()
466 .map(|c| synth_delete_diff(file_path, c));
467 (
468 raw,
469 StructuralChange {
470 change_type: "codex.delete".into(),
471 extra,
472 },
473 )
474 }
475 PatchChange::Unknown => {
476 extra.insert("operation".into(), json!("unknown"));
477 (
478 None,
479 StructuralChange {
480 change_type: "codex.unknown".into(),
481 extra,
482 },
483 )
484 }
485 }
486}
487
488fn synth_add_diff(_path: &str, content: &str) -> String {
489 let lines: Vec<&str> = content.split('\n').collect();
490 let effective: &[&str] = if lines.last() == Some(&"") {
493 &lines[..lines.len().saturating_sub(1)]
494 } else {
495 &lines[..]
496 };
497 let mut buf = format!("@@ -0,0 +1,{} @@\n", effective.len());
498 for l in effective {
499 buf.push('+');
500 buf.push_str(l);
501 buf.push('\n');
502 }
503 buf
504}
505
506fn synth_delete_diff(_path: &str, original: &str) -> String {
507 let lines: Vec<&str> = original.split('\n').collect();
508 let effective: &[&str] = if lines.last() == Some(&"") {
509 &lines[..lines.len().saturating_sub(1)]
510 } else {
511 &lines[..]
512 };
513 let mut buf = format!("@@ -1,{} +0,0 @@\n", effective.len());
514 for l in effective {
515 buf.push('-');
516 buf.push_str(l);
517 buf.push('\n');
518 }
519 buf
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::CodexConvo;
526 use std::fs;
527 use tempfile::TempDir;
528 use toolpath::v1::Document;
529
530 fn fixture_session(body: &str) -> (TempDir, CodexConvo, String) {
531 let temp = TempDir::new().unwrap();
532 let codex = temp.path().join(".codex");
533 let day = codex.join("sessions/2026/04/20");
534 fs::create_dir_all(&day).unwrap();
535 let name = "rollout-2026-04-20T10-00-00-019dabc6-8fef-7681-a054-b5bb75fcb97d";
536 fs::write(day.join(format!("{}.jsonl", name)), body).unwrap();
537 let resolver = crate::PathResolver::new().with_codex_dir(&codex);
538 (temp, CodexConvo::with_resolver(resolver), name.into())
539 }
540
541 fn minimal_body() -> String {
542 [
543 r#"{"timestamp":"2026-04-20T16:44:37.772Z","type":"session_meta","payload":{"id":"019dabc6-8fef-7681-a054-b5bb75fcb97d","timestamp":"2026-04-20T16:43:30.171Z","cwd":"/tmp/proj","originator":"codex-tui","cli_version":"0.118.0","source":"cli","git":{"commit_hash":"abc","branch":"main","repository_url":"git@example:x/y.git"}}}"#,
544 r#"{"timestamp":"2026-04-20T16:44:37.773Z","type":"turn_context","payload":{"turn_id":"t1","cwd":"/tmp/proj","model":"gpt-5.4"}}"#,
545 r#"{"timestamp":"2026-04-20T16:44:37.800Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"build me a thing"}]}}"#,
546 r#"{"timestamp":"2026-04-20T16:44:38.100Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"creating"}],"phase":"commentary"}}"#,
547 r#"{"timestamp":"2026-04-20T16:44:38.200Z","type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"pwd\"}","call_id":"c1"}}"#,
548 r#"{"timestamp":"2026-04-20T16:44:38.300Z","type":"response_item","payload":{"type":"function_call_output","call_id":"c1","output":"/tmp/proj\n"}}"#,
549 r#"{"timestamp":"2026-04-20T16:44:38.400Z","type":"event_msg","payload":{"type":"exec_command_end","call_id":"c1","command":["/bin/bash","-lc","pwd"],"stdout":"/tmp/proj\n","exit_code":0,"aggregated_output":"/tmp/proj\n"}}"#,
550 r#"{"timestamp":"2026-04-20T16:44:38.500Z","type":"response_item","payload":{"type":"custom_tool_call","call_id":"c2","name":"apply_patch","input":"*** Begin Patch\n*** Add File: /tmp/proj/a.rs\n+fn main() {}\n*** End Patch"}}"#,
551 r#"{"timestamp":"2026-04-20T16:44:38.700Z","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"c2","success":true,"changes":{"/tmp/proj/a.rs":{"type":"add","content":"fn main() {}\n"}}}}"#,
552 r#"{"timestamp":"2026-04-20T16:44:38.900Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}],"phase":"final","end_turn":true}}"#,
553 ]
554 .join("\n")
555 }
556
557 #[test]
558 fn derive_path_basic() {
559 let (_t, mgr, id) = fixture_session(&minimal_body());
560 let session = mgr.read_session(&id).unwrap();
561 let path = derive_path(&session, &DeriveConfig::default());
562
563 assert!(path.path.id.starts_with("path-codex-"));
564 assert_eq!(path.path.base.as_ref().unwrap().uri, "file:///tmp/proj");
565 assert_eq!(
566 path.path.base.as_ref().unwrap().ref_str.as_deref(),
567 Some("abc")
568 );
569 assert_eq!(path.steps.len(), 3);
571 }
572
573 #[test]
574 fn derive_path_actors_populated() {
575 let (_t, mgr, id) = fixture_session(&minimal_body());
576 let session = mgr.read_session(&id).unwrap();
577 let path = derive_path(&session, &DeriveConfig::default());
578 let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
579 assert!(actors.contains_key("human:user"));
580 assert!(actors.contains_key("agent:gpt-5.4"));
581 }
582
583 #[test]
584 fn derive_path_preserves_conversation_artifact() {
585 let (_t, mgr, id) = fixture_session(&minimal_body());
586 let session = mgr.read_session(&id).unwrap();
587 let path = derive_path(&session, &DeriveConfig::default());
588 let artifact = format!("codex://{}", session.id);
589 for step in &path.steps {
590 assert!(
591 step.change.contains_key(&artifact),
592 "step {} missing convo artifact",
593 step.step.id
594 );
595 }
596 }
597
598 #[test]
599 fn derive_path_surfaces_apply_patch_as_file_artifact() {
600 let (_t, mgr, id) = fixture_session(&minimal_body());
601 let session = mgr.read_session(&id).unwrap();
602 let path = derive_path(&session, &DeriveConfig::default());
603 let file_step = path
605 .steps
606 .iter()
607 .find(|s| s.change.contains_key("/tmp/proj/a.rs"))
608 .expect("no step carries the file artifact");
609 let change = &file_step.change["/tmp/proj/a.rs"];
610 assert!(change.raw.is_some(), "raw perspective must be populated");
611 assert!(
612 change.raw.as_ref().unwrap().contains("+fn main() {}"),
613 "raw must be a unified diff"
614 );
615 let structural = change.structural.as_ref().unwrap();
616 assert_eq!(structural.change_type, "codex.add");
617 assert_eq!(structural.extra["operation"], "add");
618 }
619
620 #[test]
621 fn derive_path_update_perspectives_preserved() {
622 let body = [
624 r#"{"timestamp":"t","type":"session_meta","payload":{"id":"s","timestamp":"t","cwd":"/p","originator":"x","cli_version":"1","source":"cli"}}"#,
625 r#"{"timestamp":"t","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"edit"}]}}"#,
626 r#"{"timestamp":"t","type":"response_item","payload":{"type":"custom_tool_call","call_id":"c","name":"apply_patch","input":"*** Update File: /p/a.rs\n@@"}}"#,
627 r#"{"timestamp":"t","type":"event_msg","payload":{"type":"patch_apply_end","call_id":"c","success":true,"changes":{"/p/a.rs":{"type":"update","unified_diff":"@@ -1 +1 @@\n-old\n+new"}}}}"#,
628 ].join("\n");
629 let (_t, mgr, id) = fixture_session(&body);
630 let session = mgr.read_session(&id).unwrap();
631 let path = derive_path(&session, &DeriveConfig::default());
632 let file_change = path
633 .steps
634 .iter()
635 .find_map(|s| s.change.get("/p/a.rs"))
636 .expect("update should land as file artifact");
637 assert_eq!(file_change.raw.as_deref(), Some("@@ -1 +1 @@\n-old\n+new"));
638 let structural = file_change.structural.as_ref().unwrap();
639 assert_eq!(structural.change_type, "codex.update");
640 }
641
642 #[test]
643 fn derive_path_validates() {
644 let (_t, mgr, id) = fixture_session(&minimal_body());
645 let session = mgr.read_session(&id).unwrap();
646 let path = derive_path(&session, &DeriveConfig::default());
647 let doc = Document::Path(path);
648 let json = doc.to_json().unwrap();
649 let parsed = Document::from_json(&json).unwrap();
651 match parsed {
652 Document::Path(p) => {
653 assert!(!p.steps.is_empty());
654 let ancestors = toolpath::v1::query::ancestors(&p.steps, &p.path.head);
655 assert_eq!(ancestors.len(), p.steps.len(), "all steps on head ancestry");
656 }
657 _ => panic!("expected Path"),
658 }
659 }
660
661 #[test]
662 fn derive_path_shell_summary() {
663 let (_t, mgr, id) = fixture_session(&minimal_body());
664 let session = mgr.read_session(&id).unwrap();
665 let path = derive_path(&session, &DeriveConfig::default());
666 let convo_artifact = format!("codex://{}", session.id);
667 let step = path
669 .steps
670 .iter()
671 .find(|s| {
672 s.change
673 .get(&convo_artifact)
674 .and_then(|c| c.structural.as_ref())
675 .and_then(|sc| sc.extra.get("tool_calls"))
676 .and_then(|v| v.as_array())
677 .map(|arr| arr.iter().any(|v| v["name"] == "exec_command"))
678 .unwrap_or(false)
679 })
680 .expect("no step with exec_command");
681 let calls = step.change[&convo_artifact]
682 .structural
683 .as_ref()
684 .unwrap()
685 .extra["tool_calls"]
686 .as_array()
687 .unwrap();
688 let exec = &calls[0];
689 assert_eq!(exec["summary"], "pwd");
690 }
691
692 #[test]
693 fn derive_path_meta_carries_git() {
694 let (_t, mgr, id) = fixture_session(&minimal_body());
695 let session = mgr.read_session(&id).unwrap();
696 let path = derive_path(&session, &DeriveConfig::default());
697 let codex_meta = &path.meta.as_ref().unwrap().extra["codex"];
698 let git = &codex_meta["git"];
699 assert_eq!(git["commit_hash"], "abc");
700 assert_eq!(git["branch"], "main");
701 }
702
703 #[test]
704 fn derive_project_multi() {
705 let (_t, mgr, id) = fixture_session(&minimal_body());
706 let session = mgr.read_session(&id).unwrap();
707 let paths = derive_project(&[session.clone(), session], &DeriveConfig::default());
708 assert_eq!(paths.len(), 2);
709 assert_eq!(paths[0].path.id, paths[1].path.id);
710 }
711
712 #[test]
713 fn synth_add_diff_has_plus_lines() {
714 let diff = synth_add_diff("a.rs", "hello\nworld\n");
715 assert!(diff.contains("+hello"));
716 assert!(diff.contains("+world"));
717 assert!(diff.starts_with("@@ -0,0 +1,2 @@"));
718 }
719
720 #[test]
721 fn synth_delete_diff_has_minus_lines() {
722 let diff = synth_delete_diff("a.rs", "gone\n");
723 assert!(diff.contains("-gone"));
724 assert!(diff.starts_with("@@ -1,1 +0,0 @@"));
725 }
726}