Skip to main content

agentic_codebase/workspace/
manager.rs

1//! Workspace manager — owns workspaces, provides cross-context queries.
2//!
3//! The [`WorkspaceManager`] is the top-level entry point. It creates workspaces,
4//! adds codebase contexts to them, and exposes query, compare, and
5//! cross-reference operations that span multiple [`CodeGraph`] instances.
6
7use std::collections::HashMap;
8
9use crate::graph::CodeGraph;
10
11// ---------------------------------------------------------------------------
12// ContextRole
13// ---------------------------------------------------------------------------
14
15/// The role a codebase plays within a workspace.
16///
17/// Roles are used to distinguish the *intent* behind each loaded codebase.
18/// For example, in a C++ to Rust migration the legacy C++ graph is `Source`
19/// and the new Rust graph is `Target`.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ContextRole {
22    /// The original codebase being migrated from or analysed.
23    Source,
24    /// The destination codebase being migrated to or built.
25    Target,
26    /// An auxiliary codebase used for reference (e.g., a library API).
27    Reference,
28    /// A codebase loaded solely for side-by-side comparison.
29    Comparison,
30}
31
32impl ContextRole {
33    /// Parse a role from a string (case-insensitive).
34    ///
35    /// Returns `None` if the string does not match any known role.
36    pub fn parse_str(s: &str) -> Option<Self> {
37        match s.to_lowercase().as_str() {
38            "source" => Some(Self::Source),
39            "target" => Some(Self::Target),
40            "reference" => Some(Self::Reference),
41            "comparison" => Some(Self::Comparison),
42            _ => None,
43        }
44    }
45
46    /// A human-readable label for this role.
47    pub fn label(&self) -> &str {
48        match self {
49            Self::Source => "source",
50            Self::Target => "target",
51            Self::Reference => "reference",
52            Self::Comparison => "comparison",
53        }
54    }
55}
56
57impl std::fmt::Display for ContextRole {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.write_str(self.label())
60    }
61}
62
63// ---------------------------------------------------------------------------
64// CodebaseContext
65// ---------------------------------------------------------------------------
66
67/// A single codebase loaded into a workspace.
68///
69/// Each context wraps a [`CodeGraph`] and carries metadata about the role it
70/// plays, its root path on disk, and an optional language hint.
71#[derive(Debug)]
72pub struct CodebaseContext {
73    /// Unique identifier within the workspace (e.g., `"ctx-1"`).
74    pub id: String,
75    /// Role this codebase plays in the workspace.
76    pub role: ContextRole,
77    /// Root path of the codebase on disk.
78    pub path: String,
79    /// Primary language of the codebase, if known.
80    pub language: Option<String>,
81    /// The parsed code graph.
82    pub graph: CodeGraph,
83}
84
85// ---------------------------------------------------------------------------
86// Workspace
87// ---------------------------------------------------------------------------
88
89/// A named collection of codebase contexts that can be queried together.
90#[derive(Debug)]
91pub struct Workspace {
92    /// Unique workspace identifier (e.g., `"ws-1"`).
93    pub id: String,
94    /// Human-readable name.
95    pub name: String,
96    /// Codebase contexts in this workspace.
97    pub contexts: Vec<CodebaseContext>,
98    /// Creation timestamp (Unix epoch microseconds).
99    pub created_at: u64,
100}
101
102// ---------------------------------------------------------------------------
103// Query result types
104// ---------------------------------------------------------------------------
105
106/// The result of a cross-context symbol query for a single context.
107#[derive(Debug)]
108pub struct CrossContextResult {
109    /// Which context produced these matches.
110    pub context_id: String,
111    /// Role of the matching context.
112    pub context_role: ContextRole,
113    /// Matching symbols within the context.
114    pub matches: Vec<SymbolMatch>,
115}
116
117/// A single symbol that matched a query.
118#[derive(Debug)]
119pub struct SymbolMatch {
120    /// Code unit ID within its graph.
121    pub unit_id: u64,
122    /// Simple symbol name.
123    pub name: String,
124    /// Fully qualified name.
125    pub qualified_name: String,
126    /// Human-readable type label (from [`CodeUnitType::label`]).
127    pub unit_type: String,
128    /// File path where the symbol is defined.
129    pub file_path: String,
130}
131
132/// Side-by-side comparison of a symbol across all contexts.
133#[derive(Debug)]
134pub struct Comparison {
135    /// The symbol name being compared.
136    pub symbol: String,
137    /// Per-context comparison entries.
138    pub contexts: Vec<ContextComparison>,
139    /// Semantic similarity score between the matched units (0.0 = unrelated,
140    /// 1.0 = identical). Defaults to 0.0 when no vector comparison is possible.
141    pub semantic_match: f32,
142    /// Structural differences observed across contexts.
143    pub structural_diff: Vec<String>,
144}
145
146/// Comparison data for one context within a [`Comparison`].
147#[derive(Debug)]
148pub struct ContextComparison {
149    /// Context identifier.
150    pub context_id: String,
151    /// Context role.
152    pub role: ContextRole,
153    /// Whether the symbol was found in this context.
154    pub found: bool,
155    /// Type label if the symbol was found.
156    pub unit_type: Option<String>,
157    /// Signature string if the symbol was found.
158    pub signature: Option<String>,
159    /// File path if the symbol was found.
160    pub file_path: Option<String>,
161}
162
163/// Cross-reference report showing where a symbol exists and where it is missing.
164#[derive(Debug)]
165pub struct CrossReference {
166    /// The symbol being referenced.
167    pub symbol: String,
168    /// Contexts (and their roles) where the symbol was found.
169    pub found_in: Vec<(String, ContextRole)>,
170    /// Contexts (and their roles) where the symbol is absent.
171    pub missing_from: Vec<(String, ContextRole)>,
172}
173
174// ---------------------------------------------------------------------------
175// WorkspaceManager
176// ---------------------------------------------------------------------------
177
178/// Owns all workspaces and provides the public API for multi-context operations.
179///
180/// # Design
181///
182/// Workspaces and contexts are identified by auto-generated string IDs
183/// (`"ws-N"`, `"ctx-N"`). The manager tracks which workspace is currently
184/// *active* to serve as a convenient default in CLI/MCP workflows.
185#[derive(Debug)]
186pub struct WorkspaceManager {
187    /// All workspaces, keyed by ID.
188    workspaces: HashMap<String, Workspace>,
189    /// The currently active workspace ID, if any.
190    active: Option<String>,
191    /// Monotonically increasing counter used to generate unique IDs.
192    next_id: u64,
193}
194
195impl WorkspaceManager {
196    // -- Construction -------------------------------------------------------
197
198    /// Create a new, empty workspace manager.
199    pub fn new() -> Self {
200        Self {
201            workspaces: HashMap::new(),
202            active: None,
203            next_id: 1,
204        }
205    }
206
207    // -- Workspace lifecycle ------------------------------------------------
208
209    /// Create a new workspace with the given name.
210    ///
211    /// The workspace becomes the active workspace and its generated ID is
212    /// returned (e.g., `"ws-1"`).
213    pub fn create(&mut self, name: &str) -> String {
214        let id = format!("ws-{}", self.next_id);
215        self.next_id += 1;
216
217        let workspace = Workspace {
218            id: id.clone(),
219            name: name.to_string(),
220            contexts: Vec::new(),
221            created_at: crate::types::now_micros(),
222        };
223
224        self.workspaces.insert(id.clone(), workspace);
225        self.active = Some(id.clone());
226        id
227    }
228
229    /// Add a codebase context to an existing workspace.
230    ///
231    /// Returns the generated context ID (e.g., `"ctx-2"`) or an error if the
232    /// workspace does not exist.
233    pub fn add_context(
234        &mut self,
235        workspace_id: &str,
236        path: &str,
237        role: ContextRole,
238        language: Option<String>,
239        graph: CodeGraph,
240    ) -> Result<String, String> {
241        let ctx_id = format!("ctx-{}", self.next_id);
242        self.next_id += 1;
243
244        let workspace = self
245            .workspaces
246            .get_mut(workspace_id)
247            .ok_or_else(|| format!("workspace '{}' not found", workspace_id))?;
248
249        workspace.contexts.push(CodebaseContext {
250            id: ctx_id.clone(),
251            role,
252            path: path.to_string(),
253            language,
254            graph,
255        });
256
257        Ok(ctx_id)
258    }
259
260    /// Return a reference to the given workspace.
261    pub fn list(&self, workspace_id: &str) -> Result<&Workspace, String> {
262        self.workspaces
263            .get(workspace_id)
264            .ok_or_else(|| format!("workspace '{}' not found", workspace_id))
265    }
266
267    /// Return the ID of the currently active workspace, if any.
268    pub fn get_active(&self) -> Option<&str> {
269        self.active.as_deref()
270    }
271
272    // -- Cross-context queries ----------------------------------------------
273
274    /// Search **all** contexts in a workspace for symbols whose name contains
275    /// `query` (case-insensitive substring match).
276    ///
277    /// Returns one [`CrossContextResult`] per context that has at least one
278    /// matching symbol.
279    pub fn query_all(
280        &self,
281        workspace_id: &str,
282        query: &str,
283    ) -> Result<Vec<CrossContextResult>, String> {
284        let workspace = self
285            .workspaces
286            .get(workspace_id)
287            .ok_or_else(|| format!("workspace '{}' not found", workspace_id))?;
288
289        let query_lower = query.to_lowercase();
290        let mut results = Vec::new();
291
292        for ctx in &workspace.contexts {
293            let matches = Self::search_graph(&ctx.graph, &query_lower);
294            if !matches.is_empty() {
295                results.push(CrossContextResult {
296                    context_id: ctx.id.clone(),
297                    context_role: ctx.role.clone(),
298                    matches,
299                });
300            }
301        }
302
303        Ok(results)
304    }
305
306    /// Search a **single** context for symbols whose name contains `query`
307    /// (case-insensitive substring match).
308    pub fn query_context(
309        &self,
310        workspace_id: &str,
311        context_id: &str,
312        query: &str,
313    ) -> Result<Vec<SymbolMatch>, String> {
314        let workspace = self
315            .workspaces
316            .get(workspace_id)
317            .ok_or_else(|| format!("workspace '{}' not found", workspace_id))?;
318
319        let ctx = workspace
320            .contexts
321            .iter()
322            .find(|c| c.id == context_id)
323            .ok_or_else(|| {
324                format!(
325                    "context '{}' not found in workspace '{}'",
326                    context_id, workspace_id
327                )
328            })?;
329
330        let query_lower = query.to_lowercase();
331        Ok(Self::search_graph(&ctx.graph, &query_lower))
332    }
333
334    /// Compare a symbol across **all** contexts in a workspace.
335    ///
336    /// For every context the method records whether the symbol was found and,
337    /// if so, its type, signature, and file path. Structural differences
338    /// (e.g., different types or signatures) are collected into
339    /// [`Comparison::structural_diff`].
340    pub fn compare(&self, workspace_id: &str, symbol: &str) -> Result<Comparison, String> {
341        let workspace = self
342            .workspaces
343            .get(workspace_id)
344            .ok_or_else(|| format!("workspace '{}' not found", workspace_id))?;
345
346        let symbol_lower = symbol.to_lowercase();
347        let mut ctx_comparisons = Vec::new();
348        let mut structural_diff = Vec::new();
349
350        // Collect per-context matches (take the first exact-name hit per context).
351        let mut first_sig: Option<String> = None;
352        let mut first_type: Option<String> = None;
353
354        for ctx in &workspace.contexts {
355            let unit = ctx
356                .graph
357                .units()
358                .iter()
359                .find(|u| u.name.to_lowercase() == symbol_lower);
360
361            match unit {
362                Some(u) => {
363                    let sig = u.signature.clone();
364                    let utype = u.unit_type.label().to_string();
365                    let fpath = u.file_path.display().to_string();
366
367                    // Detect structural differences against the first occurrence.
368                    if let Some(ref first) = first_sig {
369                        if sig.as_deref().unwrap_or("") != first.as_str() {
370                            structural_diff.push(format!(
371                                "signature differs in {}: '{}' vs '{}'",
372                                ctx.id,
373                                sig.as_deref().unwrap_or("<none>"),
374                                first,
375                            ));
376                        }
377                    } else {
378                        first_sig = Some(sig.as_deref().unwrap_or("").to_string());
379                    }
380
381                    if let Some(ref first) = first_type {
382                        if utype != *first {
383                            structural_diff.push(format!(
384                                "type differs in {}: '{}' vs '{}'",
385                                ctx.id, utype, first,
386                            ));
387                        }
388                    } else {
389                        first_type = Some(utype.clone());
390                    }
391
392                    ctx_comparisons.push(ContextComparison {
393                        context_id: ctx.id.clone(),
394                        role: ctx.role.clone(),
395                        found: true,
396                        unit_type: Some(utype),
397                        signature: sig,
398                        file_path: Some(fpath),
399                    });
400                }
401                None => {
402                    ctx_comparisons.push(ContextComparison {
403                        context_id: ctx.id.clone(),
404                        role: ctx.role.clone(),
405                        found: false,
406                        unit_type: None,
407                        signature: None,
408                        file_path: None,
409                    });
410                }
411            }
412        }
413
414        Ok(Comparison {
415            symbol: symbol.to_string(),
416            contexts: ctx_comparisons,
417            semantic_match: 0.0, // TODO: compute cosine similarity when vectors are populated
418            structural_diff,
419        })
420    }
421
422    /// Build a cross-reference report for a symbol across all contexts.
423    ///
424    /// Returns lists of contexts where the symbol was found and where it is
425    /// missing (both annotated with their [`ContextRole`]).
426    pub fn cross_reference(
427        &self,
428        workspace_id: &str,
429        symbol: &str,
430    ) -> Result<CrossReference, String> {
431        let workspace = self
432            .workspaces
433            .get(workspace_id)
434            .ok_or_else(|| format!("workspace '{}' not found", workspace_id))?;
435
436        let symbol_lower = symbol.to_lowercase();
437        let mut found_in = Vec::new();
438        let mut missing_from = Vec::new();
439
440        for ctx in &workspace.contexts {
441            let exists = ctx
442                .graph
443                .units()
444                .iter()
445                .any(|u| u.name.to_lowercase() == symbol_lower);
446
447            if exists {
448                found_in.push((ctx.id.clone(), ctx.role.clone()));
449            } else {
450                missing_from.push((ctx.id.clone(), ctx.role.clone()));
451            }
452        }
453
454        Ok(CrossReference {
455            symbol: symbol.to_string(),
456            found_in,
457            missing_from,
458        })
459    }
460
461    // -- Internal helpers ---------------------------------------------------
462
463    /// Search a single graph for units whose name contains `query_lower`.
464    ///
465    /// `query_lower` must already be lowercased.
466    fn search_graph(graph: &CodeGraph, query_lower: &str) -> Vec<SymbolMatch> {
467        graph
468            .units()
469            .iter()
470            .filter(|u| u.name.to_lowercase().contains(query_lower))
471            .map(|u| SymbolMatch {
472                unit_id: u.id,
473                name: u.name.clone(),
474                qualified_name: u.qualified_name.clone(),
475                unit_type: u.unit_type.label().to_string(),
476                file_path: u.file_path.display().to_string(),
477            })
478            .collect()
479    }
480}
481
482impl Default for WorkspaceManager {
483    fn default() -> Self {
484        Self::new()
485    }
486}
487
488// ---------------------------------------------------------------------------
489// Tests
490// ---------------------------------------------------------------------------
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use crate::types::{CodeUnit, CodeUnitType, Language, Span};
496    use std::path::PathBuf;
497
498    /// Build a tiny graph with one function unit for testing.
499    fn make_graph(name: &str, sig: Option<&str>) -> CodeGraph {
500        let mut g = CodeGraph::with_default_dimension();
501        let mut unit = CodeUnit::new(
502            CodeUnitType::Function,
503            Language::Rust,
504            name.to_string(),
505            format!("crate::{}", name),
506            PathBuf::from(format!("src/{}.rs", name)),
507            Span::new(1, 0, 10, 0),
508        );
509        if let Some(s) = sig {
510            unit.signature = Some(s.to_string());
511        }
512        g.add_unit(unit);
513        g
514    }
515
516    #[test]
517    fn create_workspace_sets_active() {
518        let mut mgr = WorkspaceManager::new();
519        let id = mgr.create("test-ws");
520        assert_eq!(mgr.get_active(), Some(id.as_str()));
521    }
522
523    #[test]
524    fn add_context_and_list() {
525        let mut mgr = WorkspaceManager::new();
526        let ws = mgr.create("migration");
527        let ctx = mgr
528            .add_context(
529                &ws,
530                "/src/cpp",
531                ContextRole::Source,
532                Some("C++".into()),
533                make_graph("foo", None),
534            )
535            .unwrap();
536        assert!(ctx.starts_with("ctx-"));
537
538        let workspace = mgr.list(&ws).unwrap();
539        assert_eq!(workspace.contexts.len(), 1);
540        assert_eq!(workspace.contexts[0].role, ContextRole::Source);
541    }
542
543    #[test]
544    fn query_all_finds_symbol() {
545        let mut mgr = WorkspaceManager::new();
546        let ws = mgr.create("q");
547        mgr.add_context(
548            &ws,
549            "/a",
550            ContextRole::Source,
551            None,
552            make_graph("process", None),
553        )
554        .unwrap();
555        mgr.add_context(
556            &ws,
557            "/b",
558            ContextRole::Target,
559            None,
560            make_graph("other", None),
561        )
562        .unwrap();
563
564        let results = mgr.query_all(&ws, "proc").unwrap();
565        assert_eq!(results.len(), 1);
566        assert_eq!(results[0].matches[0].name, "process");
567    }
568
569    #[test]
570    fn query_context_single() {
571        let mut mgr = WorkspaceManager::new();
572        let ws = mgr.create("q2");
573        let ctx = mgr
574            .add_context(
575                &ws,
576                "/a",
577                ContextRole::Source,
578                None,
579                make_graph("alpha", None),
580            )
581            .unwrap();
582
583        let matches = mgr.query_context(&ws, &ctx, "alph").unwrap();
584        assert_eq!(matches.len(), 1);
585        assert_eq!(matches[0].name, "alpha");
586    }
587
588    #[test]
589    fn compare_detects_signature_diff() {
590        let mut mgr = WorkspaceManager::new();
591        let ws = mgr.create("cmp");
592        mgr.add_context(
593            &ws,
594            "/a",
595            ContextRole::Source,
596            None,
597            make_graph("foo", Some("(int) -> bool")),
598        )
599        .unwrap();
600        mgr.add_context(
601            &ws,
602            "/b",
603            ContextRole::Target,
604            None,
605            make_graph("foo", Some("(i32) -> bool")),
606        )
607        .unwrap();
608
609        let cmp = mgr.compare(&ws, "foo").unwrap();
610        assert_eq!(cmp.contexts.len(), 2);
611        assert!(cmp.contexts[0].found);
612        assert!(cmp.contexts[1].found);
613        assert!(!cmp.structural_diff.is_empty());
614    }
615
616    #[test]
617    fn cross_reference_found_and_missing() {
618        let mut mgr = WorkspaceManager::new();
619        let ws = mgr.create("xref");
620        mgr.add_context(
621            &ws,
622            "/a",
623            ContextRole::Source,
624            None,
625            make_graph("bar", None),
626        )
627        .unwrap();
628        mgr.add_context(
629            &ws,
630            "/b",
631            ContextRole::Target,
632            None,
633            make_graph("other", None),
634        )
635        .unwrap();
636
637        let xref = mgr.cross_reference(&ws, "bar").unwrap();
638        assert_eq!(xref.found_in.len(), 1);
639        assert_eq!(xref.missing_from.len(), 1);
640    }
641
642    #[test]
643    fn context_role_roundtrip() {
644        for label in &["source", "target", "reference", "comparison"] {
645            let role = ContextRole::parse_str(label).unwrap();
646            assert_eq!(role.label(), *label);
647        }
648        assert!(ContextRole::parse_str("invalid").is_none());
649    }
650
651    #[test]
652    fn workspace_not_found_error() {
653        let mgr = WorkspaceManager::new();
654        assert!(mgr.list("ws-999").is_err());
655        assert!(mgr.query_all("ws-999", "x").is_err());
656    }
657}