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    ///
612    /// `author_name` / `author_email` override the Git commit author.
613    /// Pass empty strings to fall back to the agent identity.
614    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        // Get changeset and verify it's approved
630        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    /// Parse a file and return all detected symbols as changes.
718    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    // ── validate_path ───────────────────────────────────────────────
739
740    #[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        // Single dot is fine (current dir), only ".." is banned.
758        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        // "foo..bar" should be fine — only bare ".." as a component is banned.
803        assert!(validate_path("foo..bar.txt").is_ok());
804    }
805
806    // ── ToolFileListEntry / ToolFileListResult construction ─────────
807
808    #[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        // skip_serializing_if: empty string is omitted from JSON
836        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        // Mirrors the logic in tool_list_files: build modified set, map files.
881        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); // src/changed.rs
903        assert!(!entries[1].modified_in_session); // src/unchanged.rs
904        assert!(!entries[2].modified_in_session); // Cargo.toml
905    }
906
907    // ── ToolCodebaseSummary From<CodebaseSummary> ───────────────────
908
909    #[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    // ── ToolVerifyStepResult / ToolVerifyResult construction ────────
923
924    #[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    // ── verify_finalize status logic ────────────────────────────────
966    // The actual method requires a DB. Here we test the status derivation
967    // logic directly (the same expression used in tool_verify_finalize).
968
969    #[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    // ── merge rejection logic ───────────────────────────────────────
979    // tool_merge checks `changeset.state != "approved"`. We test that
980    // the error message format matches for various non-approved states.
981
982    #[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    // ── ToolDetectedChange construction ─────────────────────────────
1002
1003    #[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    // ── JSON serialization ──────────────────────────────────────────
1014
1015    #[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}