1use std::collections::HashMap;
8use std::path::Path;
9
10use serde::Serialize;
11use uuid::Uuid;
12
13use crate::repo::{CodebaseSummary, Engine};
14use crate::workspace::session_workspace::WorkspaceMode;
15
16fn validate_path(path: &str) -> dk_core::Result<()> {
18 if path.is_empty() {
19 return Err(dk_core::Error::InvalidInput(
20 "file path cannot be empty".into(),
21 ));
22 }
23 if path.starts_with('/') || path.starts_with('\\') {
24 return Err(dk_core::Error::InvalidInput(
25 "file path must be relative".into(),
26 ));
27 }
28 if path.contains('\0') {
29 return Err(dk_core::Error::InvalidInput(
30 "file path contains null byte".into(),
31 ));
32 }
33 for component in path.split(&['/', '\\'] as &[char]) {
36 if component == ".." {
37 return Err(dk_core::Error::InvalidInput(
38 "file path contains '..' traversal".into(),
39 ));
40 }
41 }
42 Ok(())
43}
44
45#[derive(Debug, Serialize)]
48pub struct ToolConnectResult {
49 pub session_id: String,
50 pub base_commit: String,
51 pub codebase_summary: ToolCodebaseSummary,
52 pub active_sessions: u32,
53}
54
55#[derive(Debug, Serialize)]
56pub struct ToolCodebaseSummary {
57 pub languages: Vec<String>,
58 pub total_symbols: u64,
59 pub total_files: u64,
60}
61
62impl From<CodebaseSummary> for ToolCodebaseSummary {
63 fn from(s: CodebaseSummary) -> Self {
64 Self {
65 languages: s.languages,
66 total_symbols: s.total_symbols,
67 total_files: s.total_files,
68 }
69 }
70}
71
72#[derive(Debug, Serialize)]
73pub struct ToolContextSymbol {
74 pub name: String,
75 pub qualified_name: String,
76 pub kind: String,
77 pub file_path: String,
78 pub signature: Option<String>,
79 pub source: Option<String>,
80 pub callers: Vec<String>,
81 pub callees: Vec<String>,
82}
83
84#[derive(Debug, Serialize)]
85pub struct ToolContextResult {
86 pub symbols: Vec<ToolContextSymbol>,
87 pub token_count: u32,
88 pub freshness: String,
89}
90
91#[derive(Debug, Serialize)]
92pub struct ToolFileReadResult {
93 pub content: String,
94 pub hash: String,
95 pub modified_in_session: bool,
96}
97
98#[derive(Debug, Serialize)]
99pub struct ToolFileWriteResult {
100 pub new_hash: String,
101 pub detected_changes: Vec<ToolDetectedChange>,
102}
103
104#[derive(Debug, Serialize)]
105pub struct ToolDetectedChange {
106 pub symbol_name: String,
107 pub change_type: String,
108}
109
110#[derive(Debug, Serialize)]
111pub struct ToolSubmitResult {
112 pub status: String,
113 pub version: Option<String>,
114 pub changeset_id: String,
115 pub failures: Vec<ToolVerifyFailure>,
116 pub conflicts: Vec<ToolConflict>,
117}
118
119#[derive(Debug, Serialize)]
120pub struct ToolVerifyFailure {
121 pub gate: String,
122 pub test_name: String,
123 pub error: String,
124 pub suggestion: Option<String>,
125}
126
127#[derive(Debug, Serialize)]
128pub struct ToolConflict {
129 pub file: String,
130 pub symbol: String,
131 pub our_change: String,
132 pub their_change: String,
133}
134
135#[derive(Debug, Serialize)]
136pub struct ToolStatusResult {
137 pub session_id: String,
138 pub base_commit: String,
139 pub files_modified: Vec<String>,
140 pub symbols_modified: Vec<String>,
141 pub overlay_size_bytes: u64,
142 pub active_other_sessions: u32,
143}
144
145#[derive(Debug, Serialize)]
146pub struct ToolFileListEntry {
147 pub path: String,
148 pub modified_in_session: bool,
149 #[serde(skip_serializing_if = "String::is_empty")]
152 pub modified_by_other: String,
153}
154
155#[derive(Debug, Serialize)]
156pub struct ToolFileListResult {
157 pub files: Vec<ToolFileListEntry>,
158 pub total: usize,
159}
160
161#[derive(Debug, Serialize)]
162pub struct ToolVerifyStepResult {
163 pub step_name: String,
164 pub status: String,
165 pub output: String,
166 pub required: bool,
167}
168
169#[derive(Debug, Serialize)]
170pub struct ToolVerifyResult {
171 pub changeset_id: String,
172 pub passed: bool,
173 pub steps: Vec<ToolVerifyStepResult>,
174}
175
176#[derive(Debug, Serialize)]
177pub struct ToolMergeResult {
178 pub commit_hash: String,
179 pub merged_version: String,
180 pub auto_rebased: bool,
181 pub auto_rebased_files: Vec<String>,
182 pub conflicts: Vec<ToolConflict>,
183}
184
185impl Engine {
188 pub async fn tool_connect(
190 &self,
191 repo_name: &str,
192 intent: &str,
193 agent_id: &str,
194 session_id: Uuid,
195 changeset_id: Uuid,
196 ) -> dk_core::Result<ToolConnectResult> {
197 let (repo_id, git_repo) = self.get_repo(repo_name).await?;
198 let head = git_repo
199 .head_hash()?
200 .unwrap_or_else(|| "initial".to_string());
201 drop(git_repo);
202
203 let agent_name = self.workspace_manager().next_agent_name(&repo_id);
205
206 self.changeset_store()
208 .create(repo_id, Some(session_id), agent_id, intent, Some(&head), &agent_name)
209 .await?;
210
211 self.workspace_manager()
213 .create_workspace(
214 session_id,
215 repo_id,
216 agent_id.to_string(),
217 changeset_id,
218 intent.to_string(),
219 head.clone(),
220 WorkspaceMode::Ephemeral,
221 agent_name,
222 )
223 .await?;
224
225 let summary = self.codebase_summary(repo_id).await?;
226
227 let active = self
228 .workspace_manager()
229 .active_sessions_for_repo(repo_id, Some(session_id))
230 .len() as u32;
231
232 Ok(ToolConnectResult {
233 session_id: session_id.to_string(),
234 base_commit: head,
235 codebase_summary: summary.into(),
236 active_sessions: active,
237 })
238 }
239
240 pub async fn tool_context(
242 &self,
243 session_id: Uuid,
244 query: &str,
245 depth: Option<&str>,
246 _include_tests: Option<bool>,
247 _max_tokens: Option<u32>,
248 ) -> dk_core::Result<ToolContextResult> {
249 let repo_id = {
251 let ws = self
252 .workspace_manager()
253 .get_workspace(&session_id)
254 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
255 ws.repo_id
256 };
257
258 let max_results = 50usize;
259 let symbols = self.query_symbols(repo_id, query, max_results).await?;
260
261 let depth = depth.unwrap_or("signatures");
262 let include_source = depth == "full" || depth == "call_graph";
263 let include_call_graph = depth == "call_graph";
264
265 let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
267 let work_dir = git_repo.path().to_path_buf();
268 drop(git_repo);
269
270 let mut result_symbols = Vec::with_capacity(symbols.len());
271 let mut total_chars = 0u64;
272
273 let mut file_cache: HashMap<String, Option<Vec<u8>>> = HashMap::new();
275
276 for sym in &symbols {
277 let source = if include_source {
278 let file_path_str = sym.file_path.to_string_lossy().to_string();
280
281 let file_content = if let Some(cached) = file_cache.get(&file_path_str) {
282 cached.clone()
283 } else {
284 let overlay_content = {
285 let ws = self.workspace_manager().get_workspace(&session_id);
286 ws.and_then(|ws_ref| {
287 ws_ref
288 .overlay
289 .get(&file_path_str)
290 .and_then(|entry| entry.value().content().map(|c| c.to_vec()))
291 })
292 };
293 let content = match overlay_content {
294 Some(c) => Some(c),
295 None => {
296 let full_path = work_dir.join(&sym.file_path);
297 tokio::fs::read(&full_path).await.ok()
298 }
299 };
300 file_cache.insert(file_path_str, content.clone());
301 content
302 };
303
304 let start = sym.span.start_byte as usize;
305 let end = sym.span.end_byte as usize;
306 file_content.and_then(|c| {
307 if start < c.len() && end <= c.len() {
308 Some(
309 String::from_utf8_lossy(&c[start..end]).to_string(),
310 )
311 } else {
312 None
313 }
314 })
315 } else {
316 None
317 };
318
319 let (callers, callees) = if include_call_graph {
322 let (c, e) = self.get_call_graph(repo_id, sym.id).await?;
323 (
324 c.iter().map(|s| s.qualified_name.clone()).collect(),
325 e.iter().map(|s| s.qualified_name.clone()).collect(),
326 )
327 } else {
328 (vec![], vec![])
329 };
330
331 if let Some(ref src) = source {
332 total_chars += src.len() as u64;
333 }
334
335 result_symbols.push(ToolContextSymbol {
336 name: sym.name.clone(),
337 qualified_name: sym.qualified_name.clone(),
338 kind: format!("{:?}", sym.kind),
339 file_path: sym.file_path.to_string_lossy().to_string(),
340 signature: sym.signature.clone(),
341 source,
342 callers,
343 callees,
344 });
345 }
346
347 let token_count = (total_chars / 4) as u32;
348
349 Ok(ToolContextResult {
350 symbols: result_symbols,
351 token_count,
352 freshness: "live".to_string(),
353 })
354 }
355
356 pub async fn tool_read_file(
358 &self,
359 session_id: Uuid,
360 path: &str,
361 ) -> dk_core::Result<ToolFileReadResult> {
362 validate_path(path)?;
363
364 let repo_id = {
366 let ws = self
367 .workspace_manager()
368 .get_workspace(&session_id)
369 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
370 ws.repo_id
371 };
372
373 let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
374
375 let result = {
376 let ws = self
377 .workspace_manager()
378 .get_workspace(&session_id)
379 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
380 ws.read_file(path, &git_repo)?
381 };
382 drop(git_repo);
383
384 Ok(ToolFileReadResult {
385 content: String::from_utf8_lossy(&result.content).to_string(),
386 hash: result.hash,
387 modified_in_session: result.modified_in_session,
388 })
389 }
390
391 pub async fn tool_write_file(
393 &self,
394 session_id: Uuid,
395 changeset_id: Uuid,
396 path: &str,
397 content: &str,
398 ) -> dk_core::Result<ToolFileWriteResult> {
399 validate_path(path)?;
400
401 let (repo_id, base_commit) = {
403 let ws = self
404 .workspace_manager()
405 .get_workspace(&session_id)
406 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
407 (ws.repo_id, ws.base_commit.clone())
408 };
409
410 let is_new = {
413 let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
414 git_repo.read_tree_entry(&base_commit, path).is_err()
415 };
417
418 let content_bytes = content.as_bytes().to_vec();
419
420 let new_hash = {
422 let ws = self
423 .workspace_manager()
424 .get_workspace(&session_id)
425 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
426 ws.overlay.write(path, content_bytes, is_new).await?
427 };
428
429 self.changeset_store()
431 .upsert_file(changeset_id, path, "modify", Some(content))
432 .await?;
433
434 let detected = self.detect_symbol_changes(path, content.as_bytes());
436
437 Ok(ToolFileWriteResult {
438 new_hash,
439 detected_changes: detected,
440 })
441 }
442
443 pub async fn tool_submit(
445 &self,
446 _session_id: Uuid,
447 changeset_id: Uuid,
448 _intent: &str,
449 ) -> dk_core::Result<ToolSubmitResult> {
450 self.changeset_store()
451 .update_status(changeset_id, "submitted")
452 .await?;
453
454 Ok(ToolSubmitResult {
455 status: "accepted".to_string(),
456 version: None,
457 changeset_id: changeset_id.to_string(),
458 failures: vec![],
459 conflicts: vec![],
460 })
461 }
462
463 pub async fn tool_session_status(
465 &self,
466 session_id: Uuid,
467 ) -> dk_core::Result<ToolStatusResult> {
468 let (files_modified, overlay_size_bytes, repo_id, base_commit, changeset_id) = {
469 let ws = self
470 .workspace_manager()
471 .get_workspace(&session_id)
472 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
473 (
474 ws.overlay.list_paths(),
475 ws.overlay.total_bytes() as u64,
476 ws.repo_id,
477 ws.base_commit.clone(),
478 ws.changeset_id,
479 )
480 };
481
482 let active_other = self
483 .workspace_manager()
484 .active_sessions_for_repo(repo_id, Some(session_id))
485 .len() as u32;
486
487 let symbols_modified = match self
488 .changeset_store()
489 .get_affected_symbols(changeset_id)
490 .await
491 {
492 Ok(syms) => syms.into_iter().map(|(_, qn)| qn).collect(),
493 Err(_) => vec![],
494 };
495
496 Ok(ToolStatusResult {
497 session_id: session_id.to_string(),
498 base_commit,
499 files_modified,
500 symbols_modified,
501 overlay_size_bytes,
502 active_other_sessions: active_other,
503 })
504 }
505
506 pub async fn tool_list_files(
508 &self,
509 session_id: Uuid,
510 prefix: Option<&str>,
511 ) -> dk_core::Result<ToolFileListResult> {
512 let (repo_id, modified_paths) = {
515 let ws = self
516 .workspace_manager()
517 .get_workspace(&session_id)
518 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
519 let modified: std::collections::HashSet<String> =
520 ws.overlay.list_paths().into_iter().collect();
521 (ws.repo_id, modified)
522 };
523
524 let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
525
526 let all_files = {
529 let ws = self
530 .workspace_manager()
531 .get_workspace(&session_id)
532 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
533 ws.list_files(&git_repo, false, prefix)?
534 };
535 drop(git_repo);
536
537 let wm = self.workspace_manager();
538 let total = all_files.len();
539 let files = all_files
540 .into_iter()
541 .map(|path| {
542 let modified_in_session = modified_paths.contains(&path);
543 let modified_by_other =
544 wm.describe_other_modifiers(&path, repo_id, session_id);
545 ToolFileListEntry {
546 path,
547 modified_in_session,
548 modified_by_other,
549 }
550 })
551 .collect();
552
553 Ok(ToolFileListResult { files, total })
554 }
555
556 pub async fn tool_verify_prepare(
563 &self,
564 session_id: Uuid,
565 ) -> dk_core::Result<(Uuid, String)> {
566 let (changeset_id, repo_id) = {
567 let ws = self
568 .workspace_manager()
569 .get_workspace(&session_id)
570 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
571 (ws.changeset_id, ws.repo_id)
572 };
573
574 self.changeset_store().get(changeset_id).await?;
576
577 self.changeset_store()
579 .update_status(changeset_id, "verifying")
580 .await?;
581
582 let (repo_name,): (String,) =
584 sqlx::query_as("SELECT name FROM repositories WHERE id = $1")
585 .bind(repo_id)
586 .fetch_one(&self.db)
587 .await
588 .map_err(|e| {
589 dk_core::Error::Internal(format!("failed to look up repo name: {e}"))
590 })?;
591
592 Ok((changeset_id, repo_name))
593 }
594
595 pub async fn tool_verify_finalize(
600 &self,
601 changeset_id: Uuid,
602 passed: bool,
603 ) -> dk_core::Result<()> {
604 let final_status = if passed { "approved" } else { "rejected" };
605 self.changeset_store()
606 .update_status(changeset_id, final_status)
607 .await
608 }
609
610 pub async fn tool_merge(
615 &self,
616 session_id: Uuid,
617 message: Option<&str>,
618 author_name: &str,
619 author_email: &str,
620 ) -> dk_core::Result<ToolMergeResult> {
621 let (changeset_id, repo_id) = {
622 let ws = self
623 .workspace_manager()
624 .get_workspace(&session_id)
625 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
626 (ws.changeset_id, ws.repo_id)
627 };
628
629 let changeset = self.changeset_store().get(changeset_id).await?;
631 if changeset.state != "approved" {
632 return Err(dk_core::Error::InvalidInput(format!(
633 "changeset is '{}', must be 'approved' to merge",
634 changeset.state
635 )));
636 }
637
638 let agent = changeset.agent_id.as_deref().unwrap_or("agent");
639 let commit_message = message.unwrap_or("merge changeset");
640
641 let (effective_name, effective_email) =
642 dk_core::resolve_author(author_name, author_email, agent);
643
644 let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
645
646 let merge_result = {
647 let ws = self
648 .workspace_manager()
649 .get_workspace(&session_id)
650 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
651
652 crate::workspace::merge::merge_workspace(
653 &ws,
654 &git_repo,
655 self.parser(),
656 commit_message,
657 &effective_name,
658 &effective_email,
659 )?
660 };
661 drop(git_repo);
662
663 match merge_result {
664 crate::workspace::merge::WorkspaceMergeResult::FastMerge { commit_hash } => {
665 self.changeset_store()
666 .set_merged(changeset_id, &commit_hash)
667 .await?;
668
669 Ok(ToolMergeResult {
670 commit_hash: commit_hash.clone(),
671 merged_version: commit_hash,
672 auto_rebased: false,
673 auto_rebased_files: vec![],
674 conflicts: vec![],
675 })
676 }
677
678 crate::workspace::merge::WorkspaceMergeResult::RebaseMerge {
679 commit_hash,
680 auto_rebased_files,
681 } => {
682 self.changeset_store()
683 .set_merged(changeset_id, &commit_hash)
684 .await?;
685
686 Ok(ToolMergeResult {
687 commit_hash: commit_hash.clone(),
688 merged_version: commit_hash,
689 auto_rebased: true,
690 auto_rebased_files,
691 conflicts: vec![],
692 })
693 }
694
695 crate::workspace::merge::WorkspaceMergeResult::Conflicts { conflicts } => {
696 let tool_conflicts = conflicts
697 .iter()
698 .map(|c| ToolConflict {
699 file: c.file_path.clone(),
700 symbol: c.symbol_name.clone(),
701 our_change: format!("{:?}", c.our_change),
702 their_change: format!("{:?}", c.their_change),
703 })
704 .collect();
705
706 Ok(ToolMergeResult {
707 commit_hash: String::new(),
708 merged_version: String::new(),
709 auto_rebased: false,
710 auto_rebased_files: vec![],
711 conflicts: tool_conflicts,
712 })
713 }
714 }
715 }
716
717 fn detect_symbol_changes(&self, path: &str, content: &[u8]) -> Vec<ToolDetectedChange> {
719 let rel_path = Path::new(path);
720 match self.parser().parse_file(rel_path, content) {
721 Ok(analysis) => analysis
722 .symbols
723 .iter()
724 .map(|s| ToolDetectedChange {
725 symbol_name: s.qualified_name.clone(),
726 change_type: "modified".to_string(),
727 })
728 .collect(),
729 Err(_) => vec![],
730 }
731 }
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 #[test]
741 fn validate_path_accepts_simple_relative() {
742 assert!(validate_path("src/main.rs").is_ok());
743 }
744
745 #[test]
746 fn validate_path_accepts_single_file() {
747 assert!(validate_path("Cargo.toml").is_ok());
748 }
749
750 #[test]
751 fn validate_path_accepts_nested() {
752 assert!(validate_path("a/b/c/d.txt").is_ok());
753 }
754
755 #[test]
756 fn validate_path_accepts_dot_prefix() {
757 assert!(validate_path("./src/lib.rs").is_ok());
759 }
760
761 #[test]
762 fn validate_path_rejects_empty() {
763 let err = validate_path("").unwrap_err();
764 assert!(
765 matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("empty")),
766 "expected empty error, got: {err}"
767 );
768 }
769
770 #[test]
771 fn validate_path_rejects_absolute_forward_slash() {
772 let err = validate_path("/etc/passwd").unwrap_err();
773 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("relative")));
774 }
775
776 #[test]
777 fn validate_path_rejects_absolute_backslash() {
778 let err = validate_path("\\Windows\\system32").unwrap_err();
779 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("relative")));
780 }
781
782 #[test]
783 fn validate_path_rejects_null_byte() {
784 let err = validate_path("src/\0evil.rs").unwrap_err();
785 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("null")));
786 }
787
788 #[test]
789 fn validate_path_rejects_dot_dot_traversal() {
790 let err = validate_path("src/../../../etc/passwd").unwrap_err();
791 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("traversal")));
792 }
793
794 #[test]
795 fn validate_path_rejects_backslash_traversal() {
796 let err = validate_path("src\\..\\secret.txt").unwrap_err();
797 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("traversal")));
798 }
799
800 #[test]
801 fn validate_path_allows_dot_dot_in_filename() {
802 assert!(validate_path("foo..bar.txt").is_ok());
804 }
805
806 #[test]
809 fn file_list_entry_modified_flag() {
810 let entry = ToolFileListEntry {
811 path: "src/lib.rs".into(),
812 modified_in_session: true,
813 modified_by_other: String::new(),
814 };
815 assert!(entry.modified_in_session);
816 assert_eq!(entry.path, "src/lib.rs");
817
818 let unmodified = ToolFileListEntry {
819 path: "Cargo.toml".into(),
820 modified_in_session: false,
821 modified_by_other: String::new(),
822 };
823 assert!(!unmodified.modified_in_session);
824 }
825
826 #[test]
827 fn file_list_entry_modified_by_other() {
828 let entry = ToolFileListEntry {
829 path: "src/tasks.rs".into(),
830 modified_in_session: false,
831 modified_by_other: "create_task modified by agent-2".to_string(),
832 };
833 assert_eq!(entry.modified_by_other, "create_task modified by agent-2");
834
835 let json = serde_json::to_value(&entry).unwrap();
837 assert_eq!(
838 json["modified_by_other"],
839 "create_task modified by agent-2"
840 );
841
842 let empty_entry = ToolFileListEntry {
843 path: "src/lib.rs".into(),
844 modified_in_session: false,
845 modified_by_other: String::new(),
846 };
847 let json2 = serde_json::to_value(&empty_entry).unwrap();
848 assert!(json2.get("modified_by_other").is_none());
849 }
850
851 #[test]
852 fn file_list_result_total_matches_files() {
853 let entries = vec![
854 ToolFileListEntry {
855 path: "a.rs".into(),
856 modified_in_session: false,
857 modified_by_other: String::new(),
858 },
859 ToolFileListEntry {
860 path: "b.rs".into(),
861 modified_in_session: true,
862 modified_by_other: String::new(),
863 },
864 ToolFileListEntry {
865 path: "c.rs".into(),
866 modified_in_session: false,
867 modified_by_other: String::new(),
868 },
869 ];
870 let result = ToolFileListResult {
871 total: entries.len(),
872 files: entries,
873 };
874 assert_eq!(result.total, 3);
875 assert_eq!(result.files.len(), 3);
876 }
877
878 #[test]
879 fn file_list_modified_filter_via_hashset() {
880 let modified_paths: std::collections::HashSet<String> =
882 ["src/changed.rs".to_string()].into_iter().collect();
883
884 let all_files = vec![
885 "src/changed.rs".to_string(),
886 "src/unchanged.rs".to_string(),
887 "Cargo.toml".to_string(),
888 ];
889
890 let entries: Vec<ToolFileListEntry> = all_files
891 .into_iter()
892 .map(|path| {
893 let modified_in_session = modified_paths.contains(&path);
894 ToolFileListEntry {
895 path,
896 modified_in_session,
897 modified_by_other: String::new(),
898 }
899 })
900 .collect();
901
902 assert!(entries[0].modified_in_session); assert!(!entries[1].modified_in_session); assert!(!entries[2].modified_in_session); }
906
907 #[test]
910 fn codebase_summary_from_conversion() {
911 let src = CodebaseSummary {
912 languages: vec!["Rust".into(), "TypeScript".into()],
913 total_symbols: 42,
914 total_files: 10,
915 };
916 let tool: ToolCodebaseSummary = src.into();
917 assert_eq!(tool.languages, vec!["Rust", "TypeScript"]);
918 assert_eq!(tool.total_symbols, 42);
919 assert_eq!(tool.total_files, 10);
920 }
921
922 #[test]
925 fn verify_result_passed_true() {
926 let result = ToolVerifyResult {
927 changeset_id: Uuid::new_v4().to_string(),
928 passed: true,
929 steps: vec![ToolVerifyStepResult {
930 step_name: "lint".into(),
931 status: "passed".into(),
932 output: "no warnings".into(),
933 required: true,
934 }],
935 };
936 assert!(result.passed);
937 assert_eq!(result.steps.len(), 1);
938 assert_eq!(result.steps[0].status, "passed");
939 }
940
941 #[test]
942 fn verify_result_passed_false() {
943 let result = ToolVerifyResult {
944 changeset_id: Uuid::new_v4().to_string(),
945 passed: false,
946 steps: vec![
947 ToolVerifyStepResult {
948 step_name: "lint".into(),
949 status: "passed".into(),
950 output: String::new(),
951 required: true,
952 },
953 ToolVerifyStepResult {
954 step_name: "test".into(),
955 status: "failed".into(),
956 output: "1 test failed".into(),
957 required: true,
958 },
959 ],
960 };
961 assert!(!result.passed);
962 assert_eq!(result.steps[1].status, "failed");
963 }
964
965 #[test]
970 fn verify_finalize_status_derivation() {
971 let status_for = |passed: bool| -> &str {
972 if passed { "approved" } else { "rejected" }
973 };
974 assert_eq!(status_for(true), "approved");
975 assert_eq!(status_for(false), "rejected");
976 }
977
978 #[test]
983 fn merge_rejects_non_approved_states() {
984 for state in &["submitted", "verifying", "rejected", "draft"] {
985 let err = dk_core::Error::InvalidInput(format!(
986 "changeset is '{}', must be 'approved' to merge",
987 state
988 ));
989 let msg = err.to_string();
990 assert!(
991 msg.contains("must be 'approved' to merge"),
992 "unexpected error for state '{state}': {msg}"
993 );
994 assert!(
995 msg.contains(state),
996 "error should contain the state '{state}': {msg}"
997 );
998 }
999 }
1000
1001 #[test]
1004 fn detected_change_construction() {
1005 let change = ToolDetectedChange {
1006 symbol_name: "crate::foo::Bar".into(),
1007 change_type: "modified".into(),
1008 };
1009 assert_eq!(change.symbol_name, "crate::foo::Bar");
1010 assert_eq!(change.change_type, "modified");
1011 }
1012
1013 #[test]
1016 fn tool_connect_result_serializes() {
1017 let result = ToolConnectResult {
1018 session_id: "abc-123".into(),
1019 base_commit: "deadbeef".into(),
1020 codebase_summary: ToolCodebaseSummary {
1021 languages: vec!["Rust".into()],
1022 total_symbols: 100,
1023 total_files: 5,
1024 },
1025 active_sessions: 2,
1026 };
1027 let json = serde_json::to_value(&result).unwrap();
1028 assert_eq!(json["session_id"], "abc-123");
1029 assert_eq!(json["active_sessions"], 2);
1030 assert!(json["codebase_summary"]["languages"].is_array());
1031 }
1032
1033 #[test]
1034 fn tool_file_list_result_serializes() {
1035 let result = ToolFileListResult {
1036 total: 1,
1037 files: vec![ToolFileListEntry {
1038 path: "src/lib.rs".into(),
1039 modified_in_session: true,
1040 modified_by_other: String::new(),
1041 }],
1042 };
1043 let json = serde_json::to_value(&result).unwrap();
1044 assert_eq!(json["total"], 1);
1045 assert_eq!(json["files"][0]["path"], "src/lib.rs");
1046 assert_eq!(json["files"][0]["modified_in_session"], true);
1047 }
1048
1049 #[test]
1050 fn tool_merge_result_serializes_with_conflicts() {
1051 let result = ToolMergeResult {
1052 commit_hash: String::new(),
1053 merged_version: String::new(),
1054 auto_rebased: false,
1055 auto_rebased_files: vec![],
1056 conflicts: vec![ToolConflict {
1057 file: "src/main.rs".into(),
1058 symbol: "main".into(),
1059 our_change: "added line".into(),
1060 their_change: "removed line".into(),
1061 }],
1062 };
1063 let json = serde_json::to_value(&result).unwrap();
1064 assert_eq!(json["conflicts"][0]["file"], "src/main.rs");
1065 assert_eq!(json["conflicts"][0]["symbol"], "main");
1066 }
1067}