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(
612 &self,
613 session_id: Uuid,
614 message: Option<&str>,
615 ) -> dk_core::Result<ToolMergeResult> {
616 let (changeset_id, repo_id) = {
617 let ws = self
618 .workspace_manager()
619 .get_workspace(&session_id)
620 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
621 (ws.changeset_id, ws.repo_id)
622 };
623
624 let changeset = self.changeset_store().get(changeset_id).await?;
626 if changeset.state != "approved" {
627 return Err(dk_core::Error::InvalidInput(format!(
628 "changeset is '{}', must be 'approved' to merge",
629 changeset.state
630 )));
631 }
632
633 let agent = changeset.agent_id.as_deref().unwrap_or("agent");
634 let commit_message = message.unwrap_or("merge changeset");
635
636 let (_, git_repo) = self.get_repo_by_db_id(repo_id).await?;
637
638 let merge_result = {
639 let ws = self
640 .workspace_manager()
641 .get_workspace(&session_id)
642 .ok_or_else(|| dk_core::Error::SessionNotFound(session_id.to_string()))?;
643
644 crate::workspace::merge::merge_workspace(
645 &ws,
646 &git_repo,
647 self.parser(),
648 commit_message,
649 agent,
650 &format!("{agent}@dkod.dev"),
651 )?
652 };
653 drop(git_repo);
654
655 match merge_result {
656 crate::workspace::merge::WorkspaceMergeResult::FastMerge { commit_hash } => {
657 self.changeset_store()
658 .set_merged(changeset_id, &commit_hash)
659 .await?;
660
661 Ok(ToolMergeResult {
662 commit_hash: commit_hash.clone(),
663 merged_version: commit_hash,
664 auto_rebased: false,
665 auto_rebased_files: vec![],
666 conflicts: vec![],
667 })
668 }
669
670 crate::workspace::merge::WorkspaceMergeResult::RebaseMerge {
671 commit_hash,
672 auto_rebased_files,
673 } => {
674 self.changeset_store()
675 .set_merged(changeset_id, &commit_hash)
676 .await?;
677
678 Ok(ToolMergeResult {
679 commit_hash: commit_hash.clone(),
680 merged_version: commit_hash,
681 auto_rebased: true,
682 auto_rebased_files,
683 conflicts: vec![],
684 })
685 }
686
687 crate::workspace::merge::WorkspaceMergeResult::Conflicts { conflicts } => {
688 let tool_conflicts = conflicts
689 .iter()
690 .map(|c| ToolConflict {
691 file: c.file_path.clone(),
692 symbol: c.symbol_name.clone(),
693 our_change: format!("{:?}", c.our_change),
694 their_change: format!("{:?}", c.their_change),
695 })
696 .collect();
697
698 Ok(ToolMergeResult {
699 commit_hash: String::new(),
700 merged_version: String::new(),
701 auto_rebased: false,
702 auto_rebased_files: vec![],
703 conflicts: tool_conflicts,
704 })
705 }
706 }
707 }
708
709 fn detect_symbol_changes(&self, path: &str, content: &[u8]) -> Vec<ToolDetectedChange> {
711 let rel_path = Path::new(path);
712 match self.parser().parse_file(rel_path, content) {
713 Ok(analysis) => analysis
714 .symbols
715 .iter()
716 .map(|s| ToolDetectedChange {
717 symbol_name: s.qualified_name.clone(),
718 change_type: "modified".to_string(),
719 })
720 .collect(),
721 Err(_) => vec![],
722 }
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 #[test]
733 fn validate_path_accepts_simple_relative() {
734 assert!(validate_path("src/main.rs").is_ok());
735 }
736
737 #[test]
738 fn validate_path_accepts_single_file() {
739 assert!(validate_path("Cargo.toml").is_ok());
740 }
741
742 #[test]
743 fn validate_path_accepts_nested() {
744 assert!(validate_path("a/b/c/d.txt").is_ok());
745 }
746
747 #[test]
748 fn validate_path_accepts_dot_prefix() {
749 assert!(validate_path("./src/lib.rs").is_ok());
751 }
752
753 #[test]
754 fn validate_path_rejects_empty() {
755 let err = validate_path("").unwrap_err();
756 assert!(
757 matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("empty")),
758 "expected empty error, got: {err}"
759 );
760 }
761
762 #[test]
763 fn validate_path_rejects_absolute_forward_slash() {
764 let err = validate_path("/etc/passwd").unwrap_err();
765 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("relative")));
766 }
767
768 #[test]
769 fn validate_path_rejects_absolute_backslash() {
770 let err = validate_path("\\Windows\\system32").unwrap_err();
771 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("relative")));
772 }
773
774 #[test]
775 fn validate_path_rejects_null_byte() {
776 let err = validate_path("src/\0evil.rs").unwrap_err();
777 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("null")));
778 }
779
780 #[test]
781 fn validate_path_rejects_dot_dot_traversal() {
782 let err = validate_path("src/../../../etc/passwd").unwrap_err();
783 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("traversal")));
784 }
785
786 #[test]
787 fn validate_path_rejects_backslash_traversal() {
788 let err = validate_path("src\\..\\secret.txt").unwrap_err();
789 assert!(matches!(err, dk_core::Error::InvalidInput(ref msg) if msg.contains("traversal")));
790 }
791
792 #[test]
793 fn validate_path_allows_dot_dot_in_filename() {
794 assert!(validate_path("foo..bar.txt").is_ok());
796 }
797
798 #[test]
801 fn file_list_entry_modified_flag() {
802 let entry = ToolFileListEntry {
803 path: "src/lib.rs".into(),
804 modified_in_session: true,
805 modified_by_other: String::new(),
806 };
807 assert!(entry.modified_in_session);
808 assert_eq!(entry.path, "src/lib.rs");
809
810 let unmodified = ToolFileListEntry {
811 path: "Cargo.toml".into(),
812 modified_in_session: false,
813 modified_by_other: String::new(),
814 };
815 assert!(!unmodified.modified_in_session);
816 }
817
818 #[test]
819 fn file_list_entry_modified_by_other() {
820 let entry = ToolFileListEntry {
821 path: "src/tasks.rs".into(),
822 modified_in_session: false,
823 modified_by_other: "create_task modified by agent-2".to_string(),
824 };
825 assert_eq!(entry.modified_by_other, "create_task modified by agent-2");
826
827 let json = serde_json::to_value(&entry).unwrap();
829 assert_eq!(
830 json["modified_by_other"],
831 "create_task modified by agent-2"
832 );
833
834 let empty_entry = ToolFileListEntry {
835 path: "src/lib.rs".into(),
836 modified_in_session: false,
837 modified_by_other: String::new(),
838 };
839 let json2 = serde_json::to_value(&empty_entry).unwrap();
840 assert!(json2.get("modified_by_other").is_none());
841 }
842
843 #[test]
844 fn file_list_result_total_matches_files() {
845 let entries = vec![
846 ToolFileListEntry {
847 path: "a.rs".into(),
848 modified_in_session: false,
849 modified_by_other: String::new(),
850 },
851 ToolFileListEntry {
852 path: "b.rs".into(),
853 modified_in_session: true,
854 modified_by_other: String::new(),
855 },
856 ToolFileListEntry {
857 path: "c.rs".into(),
858 modified_in_session: false,
859 modified_by_other: String::new(),
860 },
861 ];
862 let result = ToolFileListResult {
863 total: entries.len(),
864 files: entries,
865 };
866 assert_eq!(result.total, 3);
867 assert_eq!(result.files.len(), 3);
868 }
869
870 #[test]
871 fn file_list_modified_filter_via_hashset() {
872 let modified_paths: std::collections::HashSet<String> =
874 ["src/changed.rs".to_string()].into_iter().collect();
875
876 let all_files = vec![
877 "src/changed.rs".to_string(),
878 "src/unchanged.rs".to_string(),
879 "Cargo.toml".to_string(),
880 ];
881
882 let entries: Vec<ToolFileListEntry> = all_files
883 .into_iter()
884 .map(|path| {
885 let modified_in_session = modified_paths.contains(&path);
886 ToolFileListEntry {
887 path,
888 modified_in_session,
889 modified_by_other: String::new(),
890 }
891 })
892 .collect();
893
894 assert!(entries[0].modified_in_session); assert!(!entries[1].modified_in_session); assert!(!entries[2].modified_in_session); }
898
899 #[test]
902 fn codebase_summary_from_conversion() {
903 let src = CodebaseSummary {
904 languages: vec!["Rust".into(), "TypeScript".into()],
905 total_symbols: 42,
906 total_files: 10,
907 };
908 let tool: ToolCodebaseSummary = src.into();
909 assert_eq!(tool.languages, vec!["Rust", "TypeScript"]);
910 assert_eq!(tool.total_symbols, 42);
911 assert_eq!(tool.total_files, 10);
912 }
913
914 #[test]
917 fn verify_result_passed_true() {
918 let result = ToolVerifyResult {
919 changeset_id: Uuid::new_v4().to_string(),
920 passed: true,
921 steps: vec![ToolVerifyStepResult {
922 step_name: "lint".into(),
923 status: "passed".into(),
924 output: "no warnings".into(),
925 required: true,
926 }],
927 };
928 assert!(result.passed);
929 assert_eq!(result.steps.len(), 1);
930 assert_eq!(result.steps[0].status, "passed");
931 }
932
933 #[test]
934 fn verify_result_passed_false() {
935 let result = ToolVerifyResult {
936 changeset_id: Uuid::new_v4().to_string(),
937 passed: false,
938 steps: vec![
939 ToolVerifyStepResult {
940 step_name: "lint".into(),
941 status: "passed".into(),
942 output: String::new(),
943 required: true,
944 },
945 ToolVerifyStepResult {
946 step_name: "test".into(),
947 status: "failed".into(),
948 output: "1 test failed".into(),
949 required: true,
950 },
951 ],
952 };
953 assert!(!result.passed);
954 assert_eq!(result.steps[1].status, "failed");
955 }
956
957 #[test]
962 fn verify_finalize_status_derivation() {
963 let status_for = |passed: bool| -> &str {
964 if passed { "approved" } else { "rejected" }
965 };
966 assert_eq!(status_for(true), "approved");
967 assert_eq!(status_for(false), "rejected");
968 }
969
970 #[test]
975 fn merge_rejects_non_approved_states() {
976 for state in &["submitted", "verifying", "rejected", "draft"] {
977 let err = dk_core::Error::InvalidInput(format!(
978 "changeset is '{}', must be 'approved' to merge",
979 state
980 ));
981 let msg = err.to_string();
982 assert!(
983 msg.contains("must be 'approved' to merge"),
984 "unexpected error for state '{state}': {msg}"
985 );
986 assert!(
987 msg.contains(state),
988 "error should contain the state '{state}': {msg}"
989 );
990 }
991 }
992
993 #[test]
996 fn detected_change_construction() {
997 let change = ToolDetectedChange {
998 symbol_name: "crate::foo::Bar".into(),
999 change_type: "modified".into(),
1000 };
1001 assert_eq!(change.symbol_name, "crate::foo::Bar");
1002 assert_eq!(change.change_type, "modified");
1003 }
1004
1005 #[test]
1008 fn tool_connect_result_serializes() {
1009 let result = ToolConnectResult {
1010 session_id: "abc-123".into(),
1011 base_commit: "deadbeef".into(),
1012 codebase_summary: ToolCodebaseSummary {
1013 languages: vec!["Rust".into()],
1014 total_symbols: 100,
1015 total_files: 5,
1016 },
1017 active_sessions: 2,
1018 };
1019 let json = serde_json::to_value(&result).unwrap();
1020 assert_eq!(json["session_id"], "abc-123");
1021 assert_eq!(json["active_sessions"], 2);
1022 assert!(json["codebase_summary"]["languages"].is_array());
1023 }
1024
1025 #[test]
1026 fn tool_file_list_result_serializes() {
1027 let result = ToolFileListResult {
1028 total: 1,
1029 files: vec![ToolFileListEntry {
1030 path: "src/lib.rs".into(),
1031 modified_in_session: true,
1032 modified_by_other: String::new(),
1033 }],
1034 };
1035 let json = serde_json::to_value(&result).unwrap();
1036 assert_eq!(json["total"], 1);
1037 assert_eq!(json["files"][0]["path"], "src/lib.rs");
1038 assert_eq!(json["files"][0]["modified_in_session"], true);
1039 }
1040
1041 #[test]
1042 fn tool_merge_result_serializes_with_conflicts() {
1043 let result = ToolMergeResult {
1044 commit_hash: String::new(),
1045 merged_version: String::new(),
1046 auto_rebased: false,
1047 auto_rebased_files: vec![],
1048 conflicts: vec![ToolConflict {
1049 file: "src/main.rs".into(),
1050 symbol: "main".into(),
1051 our_change: "added line".into(),
1052 their_change: "removed line".into(),
1053 }],
1054 };
1055 let json = serde_json::to_value(&result).unwrap();
1056 assert_eq!(json["conflicts"][0]["file"], "src/main.rs");
1057 assert_eq!(json["conflicts"][0]["symbol"], "main");
1058 }
1059}