Skip to main content

dk_engine/
tool_ops.rs

1//! High-level tool operations for the Programmatic Tool Calling interface.
2//!
3//! Each method corresponds to one dkod tool (`dkod_connect`, `dkod_context`,
4//! etc.). Both the gRPC handlers in dk-protocol and the HTTP fulfillment
5//! endpoint in dk-platform call these methods — no logic duplication.
6
7use 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
16/// Validate a file path for safety (no traversal, no absolute paths).
17fn 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    // Split on both forward-slash and backslash to prevent traversal via
34    // Windows-style paths like "foo\..\bar".
35    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// ── Result types (Serialize for JSON fulfillment responses) ──
46
47#[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    /// Describes which other sessions modified this file and what symbols.
150    /// Empty if no other session has touched the file.
151    #[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
185// ── Tool operation implementations on Engine ──
186
187impl Engine {
188    /// CONNECT — establish an isolated session workspace.
189    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        // Auto-assign agent name for tool_connect path
204        let agent_name = self.workspace_manager().next_agent_name(&repo_id);
205
206        // Create changeset
207        self.changeset_store()
208            .create(repo_id, Some(session_id), agent_id, intent, Some(&head), &agent_name)
209            .await?;
210
211        // Create workspace (agent_id is AgentId = String)
212        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    /// CONTEXT — semantic code search through the session workspace.
241    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        // Get workspace info, then drop the guard
250        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        // Get repo path for source reading
266        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        // Cache file contents to avoid re-reading the same file for multiple symbols.
274        let mut file_cache: HashMap<String, Option<Vec<u8>>> = HashMap::new();
275
276        for sym in &symbols {
277            let source = if include_source {
278                // Try workspace overlay first, then base tree via working directory
279                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            // TODO(perf): Batch-fetch call graph edges for all symbol IDs in a
320            // single query instead of N sequential get_call_graph calls.
321            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    /// FILE_READ — read a file through the session workspace overlay.
357    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        // Single workspace lookup: extract repo_id and read file in one guard scope.
365        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    /// FILE_WRITE — write a file to the session workspace overlay.
392    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        // Single workspace lookup: extract repo_id and base_commit together.
402        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        // Determine is_new synchronously, then drop git_repo before any
411        // async work so the future stays Send (gix::Repository has RefCell).
412        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            // git_repo dropped here
416        };
417
418        let content_bytes = content.as_bytes().to_vec();
419
420        // Write to overlay without holding git_repo across .await
421        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        // Record in changeset
430        self.changeset_store()
431            .upsert_file(changeset_id, path, "modify", Some(content))
432            .await?;
433
434        // Detect symbol changes
435        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    /// SUBMIT — submit the session's workspace changes as a changeset.
444    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    /// SESSION_STATUS — get the current workspace state.
464    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    /// LIST_FILES — list files visible in the session workspace.
507    pub async fn tool_list_files(
508        &self,
509        session_id: Uuid,
510        prefix: Option<&str>,
511    ) -> dk_core::Result<ToolFileListResult> {
512        // First lookup: extract repo_id and modified paths from the workspace
513        // in a single DashMap guard scope to avoid race conditions.
514        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        // Second lookup: list_files needs the git_repo which required an async
527        // call above, so a second guard acquisition is unavoidable here.
528        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    /// VERIFY — prepare a session's changeset for verification.
557    ///
558    /// Returns `(changeset_id, repo_name)` after validating the session,
559    /// checking the changeset, and updating its status to "verifying".
560    /// The actual runner invocation must be done by the caller (since
561    /// dk-runner depends on dk-engine, not the other way around).
562    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        // Verify changeset exists
575        self.changeset_store().get(changeset_id).await?;
576
577        // Update status to verifying
578        self.changeset_store()
579            .update_status(changeset_id, "verifying")
580            .await?;
581
582        // Look up repo name by ID for the runner
583        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    /// VERIFY — finalize after the runner has completed.
596    ///
597    /// Updates the changeset status to "approved" or "rejected" based on
598    /// whether all steps passed.
599    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    /// MERGE — merge the verified changeset into a Git commit.
611    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        // Get changeset and verify it's approved
625        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    /// Parse a file and return all detected symbols as changes.
710    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    // ── validate_path ───────────────────────────────────────────────
731
732    #[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        // Single dot is fine (current dir), only ".." is banned.
750        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        // "foo..bar" should be fine — only bare ".." as a component is banned.
795        assert!(validate_path("foo..bar.txt").is_ok());
796    }
797
798    // ── ToolFileListEntry / ToolFileListResult construction ─────────
799
800    #[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        // skip_serializing_if: empty string is omitted from JSON
828        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        // Mirrors the logic in tool_list_files: build modified set, map files.
873        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); // src/changed.rs
895        assert!(!entries[1].modified_in_session); // src/unchanged.rs
896        assert!(!entries[2].modified_in_session); // Cargo.toml
897    }
898
899    // ── ToolCodebaseSummary From<CodebaseSummary> ───────────────────
900
901    #[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    // ── ToolVerifyStepResult / ToolVerifyResult construction ────────
915
916    #[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    // ── verify_finalize status logic ────────────────────────────────
958    // The actual method requires a DB. Here we test the status derivation
959    // logic directly (the same expression used in tool_verify_finalize).
960
961    #[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    // ── merge rejection logic ───────────────────────────────────────
971    // tool_merge checks `changeset.state != "approved"`. We test that
972    // the error message format matches for various non-approved states.
973
974    #[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    // ── ToolDetectedChange construction ─────────────────────────────
994
995    #[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    // ── JSON serialization ──────────────────────────────────────────
1006
1007    #[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}