Skip to main content

track_core/
project_catalog.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6// =============================================================================
7// Project Catalog
8// =============================================================================
9//
10// Project discovery produces more than a flat list of repositories. The rest
11// of the application needs one place that can answer:
12// - which canonical projects exist
13// - which aliases map onto them
14// - which names should be exposed to the model prompt
15//
16// Keeping that logic in one domain type prevents alias handling from being
17// split across discovery, prompt building, and capture validation.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct ProjectInfo {
20    #[serde(rename = "canonicalName")]
21    pub canonical_name: String,
22    #[serde(with = "path_string")]
23    pub path: PathBuf,
24    pub aliases: Vec<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct ProjectCatalog {
29    projects: Vec<ProjectInfo>,
30    lookup_by_name: BTreeMap<String, usize>,
31}
32
33impl ProjectCatalog {
34    pub fn new(projects: Vec<ProjectInfo>) -> Self {
35        let mut lookup_by_name = BTreeMap::new();
36
37        for (index, project) in projects.iter().enumerate() {
38            lookup_by_name
39                .entry(normalize_lookup_key(&project.canonical_name))
40                .or_insert(index);
41        }
42
43        for (index, project) in projects.iter().enumerate() {
44            for alias in &project.aliases {
45                lookup_by_name
46                    .entry(normalize_lookup_key(alias))
47                    .or_insert(index);
48            }
49        }
50
51        Self {
52            projects,
53            lookup_by_name,
54        }
55    }
56
57    pub fn is_empty(&self) -> bool {
58        self.projects.is_empty()
59    }
60
61    pub fn projects(&self) -> &[ProjectInfo] {
62        &self.projects
63    }
64
65    pub fn into_projects(self) -> Vec<ProjectInfo> {
66        self.projects
67    }
68
69    pub fn resolve(&self, name: &str) -> Option<&ProjectInfo> {
70        let key = normalize_lookup_key(name);
71        let index = self.lookup_by_name.get(&key)?;
72        self.projects.get(*index)
73    }
74}
75
76fn normalize_lookup_key(value: &str) -> String {
77    value.trim().to_lowercase()
78}
79
80mod path_string {
81    use std::path::PathBuf;
82
83    use serde::{Deserialize, Deserializer, Serializer};
84
85    pub fn serialize<S>(path: &PathBuf, serializer: S) -> Result<S::Ok, S::Error>
86    where
87        S: Serializer,
88    {
89        serializer.serialize_str(&path.to_string_lossy())
90    }
91
92    pub fn deserialize<'de, D>(deserializer: D) -> Result<PathBuf, D::Error>
93    where
94        D: Deserializer<'de>,
95    {
96        let path = String::deserialize(deserializer)?;
97        Ok(PathBuf::from(path))
98    }
99}