1use std::collections::HashMap;
8
9use crate::graph::CodeGraph;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ContextRole {
22 Source,
24 Target,
26 Reference,
28 Comparison,
30}
31
32impl ContextRole {
33 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 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#[derive(Debug)]
72pub struct CodebaseContext {
73 pub id: String,
75 pub role: ContextRole,
77 pub path: String,
79 pub language: Option<String>,
81 pub graph: CodeGraph,
83}
84
85#[derive(Debug)]
91pub struct Workspace {
92 pub id: String,
94 pub name: String,
96 pub contexts: Vec<CodebaseContext>,
98 pub created_at: u64,
100}
101
102#[derive(Debug)]
108pub struct CrossContextResult {
109 pub context_id: String,
111 pub context_role: ContextRole,
113 pub matches: Vec<SymbolMatch>,
115}
116
117#[derive(Debug)]
119pub struct SymbolMatch {
120 pub unit_id: u64,
122 pub name: String,
124 pub qualified_name: String,
126 pub unit_type: String,
128 pub file_path: String,
130}
131
132#[derive(Debug)]
134pub struct Comparison {
135 pub symbol: String,
137 pub contexts: Vec<ContextComparison>,
139 pub semantic_match: f32,
142 pub structural_diff: Vec<String>,
144}
145
146#[derive(Debug)]
148pub struct ContextComparison {
149 pub context_id: String,
151 pub role: ContextRole,
153 pub found: bool,
155 pub unit_type: Option<String>,
157 pub signature: Option<String>,
159 pub file_path: Option<String>,
161}
162
163#[derive(Debug)]
165pub struct CrossReference {
166 pub symbol: String,
168 pub found_in: Vec<(String, ContextRole)>,
170 pub missing_from: Vec<(String, ContextRole)>,
172}
173
174#[derive(Debug)]
186pub struct WorkspaceManager {
187 workspaces: HashMap<String, Workspace>,
189 active: Option<String>,
191 next_id: u64,
193}
194
195impl WorkspaceManager {
196 pub fn new() -> Self {
200 Self {
201 workspaces: HashMap::new(),
202 active: None,
203 next_id: 1,
204 }
205 }
206
207 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 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 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 pub fn get_active(&self) -> Option<&str> {
269 self.active.as_deref()
270 }
271
272 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 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 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 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 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, structural_diff,
419 })
420 }
421
422 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 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#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::types::{CodeUnit, CodeUnitType, Language, Span};
496 use std::path::PathBuf;
497
498 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}