Skip to main content

track_core/
project_repository.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5use sqlx::Row;
6
7use crate::database::DatabaseContext;
8use crate::errors::{ErrorCode, TrackError};
9use crate::path_component::validate_single_normal_path_component;
10use crate::project_catalog::ProjectInfo;
11
12const DEFAULT_BASE_BRANCH: &str = "main";
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct ProjectMetadata {
16    #[serde(rename = "repoUrl")]
17    pub repo_url: String,
18    #[serde(rename = "gitUrl")]
19    pub git_url: String,
20    #[serde(rename = "baseBranch")]
21    pub base_branch: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub description: Option<String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct ProjectRecord {
28    #[serde(rename = "canonicalName")]
29    pub canonical_name: String,
30    pub aliases: Vec<String>,
31    pub metadata: ProjectMetadata,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
35pub struct ProjectMetadataUpdateInput {
36    #[serde(rename = "repoUrl")]
37    pub repo_url: String,
38    #[serde(rename = "gitUrl")]
39    pub git_url: String,
40    #[serde(rename = "baseBranch")]
41    pub base_branch: String,
42    pub description: Option<String>,
43}
44
45impl ProjectMetadataUpdateInput {
46    pub fn validate(self) -> Result<ProjectMetadata, TrackError> {
47        let repo_url = self.repo_url.trim().to_owned();
48        let git_url = self.git_url.trim().to_owned();
49        let base_branch = self.base_branch.trim().to_owned();
50        let description = self
51            .description
52            .map(|value| value.trim().to_owned())
53            .filter(|value| !value.is_empty());
54
55        if repo_url.is_empty() || git_url.is_empty() || base_branch.is_empty() {
56            return Err(TrackError::new(
57                ErrorCode::InvalidProjectMetadata,
58                "Project metadata requires repo URL, git URL, and base branch.",
59            ));
60        }
61
62        Ok(ProjectMetadata {
63            repo_url,
64            git_url,
65            base_branch,
66            description,
67        })
68    }
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
72pub struct ProjectUpsertInput {
73    #[serde(rename = "canonicalName")]
74    pub canonical_name: String,
75    #[serde(default)]
76    pub aliases: Vec<String>,
77    #[serde(flatten)]
78    pub metadata: ProjectMetadataUpdateInput,
79}
80
81impl ProjectUpsertInput {
82    pub fn validate(self) -> Result<(String, Vec<String>, ProjectMetadata), TrackError> {
83        let canonical_name = validate_single_normal_path_component(
84            &self.canonical_name,
85            "Project canonical name",
86            ErrorCode::InvalidPathComponent,
87        )?;
88        let mut aliases = self
89            .aliases
90            .into_iter()
91            .map(|alias| {
92                validate_single_normal_path_component(
93                    &alias,
94                    "Project alias",
95                    ErrorCode::InvalidPathComponent,
96                )
97            })
98            .collect::<Result<Vec<_>, _>>()?;
99        aliases.sort();
100        aliases.dedup();
101
102        Ok((canonical_name, aliases, self.metadata.validate()?))
103    }
104}
105
106#[derive(Debug, Clone)]
107pub struct ProjectRepository {
108    database: DatabaseContext,
109}
110
111impl ProjectRepository {
112    pub fn new(database_path: Option<PathBuf>) -> Result<Self, TrackError> {
113        let database = DatabaseContext::new(database_path)?;
114        database.initialize()?;
115
116        Ok(Self { database })
117    }
118
119    pub fn ensure_project(&self, project: &ProjectInfo) -> Result<ProjectRecord, TrackError> {
120        let metadata = build_default_metadata(project);
121        self.upsert_project_by_name(&project.canonical_name, metadata, project.aliases.clone())
122    }
123
124    pub(crate) fn database_context(&self) -> DatabaseContext {
125        self.database.clone()
126    }
127
128    pub fn list_projects(&self) -> Result<Vec<ProjectRecord>, TrackError> {
129        self.database.run(move |connection| {
130            Box::pin(async move {
131                let rows = sqlx::query(
132                    r#"
133                    SELECT canonical_name, repo_url, git_url, base_branch, description
134                    FROM projects
135                    ORDER BY canonical_name ASC
136                    "#,
137                )
138                .fetch_all(&mut *connection)
139                .await
140                .map_err(|error| {
141                    TrackError::new(
142                        ErrorCode::ProjectWriteFailed,
143                        format!("Could not load projects from SQLite: {error}"),
144                    )
145                })?;
146
147                let mut records = Vec::with_capacity(rows.len());
148                for row in rows {
149                    let canonical_name = row.get::<String, _>("canonical_name");
150                    records.push(ProjectRecord {
151                        aliases: load_aliases(connection, &canonical_name).await?,
152                        metadata: ProjectMetadata {
153                            repo_url: row.get::<String, _>("repo_url"),
154                            git_url: row.get::<String, _>("git_url"),
155                            base_branch: row.get::<String, _>("base_branch"),
156                            description: row.get::<Option<String>, _>("description"),
157                        },
158                        canonical_name,
159                    });
160                }
161
162                Ok(records)
163            })
164        })
165    }
166
167    pub fn get_project_by_name(&self, canonical_name: &str) -> Result<ProjectRecord, TrackError> {
168        let canonical_name = validate_single_normal_path_component(
169            canonical_name,
170            "Project canonical name",
171            ErrorCode::InvalidPathComponent,
172        )?;
173
174        self.database.run(move |connection| {
175            Box::pin(async move {
176                let row = sqlx::query(
177                    r#"
178                    SELECT canonical_name, repo_url, git_url, base_branch, description
179                    FROM projects
180                    WHERE canonical_name = ?1
181                    "#,
182                )
183                .bind(&canonical_name)
184                .fetch_optional(&mut *connection)
185                .await
186                .map_err(|error| {
187                    TrackError::new(
188                        ErrorCode::ProjectWriteFailed,
189                        format!("Could not load project {canonical_name} from SQLite: {error}"),
190                    )
191                })?
192                .ok_or_else(|| {
193                    TrackError::new(
194                        ErrorCode::ProjectNotFound,
195                        format!("Project {canonical_name} was not found."),
196                    )
197                })?;
198
199                Ok(ProjectRecord {
200                    aliases: load_aliases(connection, &canonical_name).await?,
201                    metadata: ProjectMetadata {
202                        repo_url: row.get::<String, _>("repo_url"),
203                        git_url: row.get::<String, _>("git_url"),
204                        base_branch: row.get::<String, _>("base_branch"),
205                        description: row.get::<Option<String>, _>("description"),
206                    },
207                    canonical_name,
208                })
209            })
210        })
211    }
212
213    pub fn update_project_by_name(
214        &self,
215        canonical_name: &str,
216        metadata: ProjectMetadata,
217    ) -> Result<ProjectRecord, TrackError> {
218        let existing = self.get_project_by_name(canonical_name)?;
219        self.upsert_project_by_name(&existing.canonical_name, metadata, existing.aliases)
220    }
221
222    pub fn upsert_project(&self, input: ProjectUpsertInput) -> Result<ProjectRecord, TrackError> {
223        let (canonical_name, aliases, metadata) = input.validate()?;
224        self.upsert_project_by_name(&canonical_name, metadata, aliases)
225    }
226
227    pub fn upsert_project_by_name(
228        &self,
229        canonical_name: &str,
230        metadata: ProjectMetadata,
231        aliases: Vec<String>,
232    ) -> Result<ProjectRecord, TrackError> {
233        let canonical_name = validate_single_normal_path_component(
234            canonical_name,
235            "Project canonical name",
236            ErrorCode::InvalidPathComponent,
237        )?;
238
239        self.database.transaction(move |connection| {
240            Box::pin(async move {
241                // Project registration is intentionally additive by default so
242                // a routine re-registration cannot silently discard aliases
243                // that were migrated from legacy state or added earlier.
244                let mut merged_aliases = load_aliases(connection, &canonical_name).await?;
245                merged_aliases.extend(aliases);
246                merged_aliases.retain(|alias| alias != &canonical_name);
247                merged_aliases.sort();
248                merged_aliases.dedup();
249
250                // Alias registration is part of the same logical write as the
251                // project metadata update. We therefore reject conflicts before
252                // mutating anything so callers never observe a half-applied
253                // registration when another project already owns an alias.
254                ensure_aliases_are_available(connection, &canonical_name, &merged_aliases).await?;
255
256                sqlx::query(
257                    r#"
258                    INSERT INTO projects (canonical_name, repo_url, git_url, base_branch, description)
259                    VALUES (?1, ?2, ?3, ?4, ?5)
260                    ON CONFLICT(canonical_name) DO UPDATE SET
261                        repo_url = excluded.repo_url,
262                        git_url = excluded.git_url,
263                        base_branch = excluded.base_branch,
264                        description = excluded.description
265                    "#,
266                )
267                .bind(&canonical_name)
268                .bind(&metadata.repo_url)
269                .bind(&metadata.git_url)
270                .bind(&metadata.base_branch)
271                .bind(metadata.description.as_deref())
272                .execute(&mut *connection)
273                .await
274                .map_err(|error| {
275                    TrackError::new(
276                        ErrorCode::ProjectWriteFailed,
277                        format!("Could not save project {canonical_name}: {error}"),
278                    )
279                })?;
280
281                for alias in &merged_aliases {
282                    sqlx::query(
283                        r#"
284                        INSERT INTO project_aliases (canonical_name, alias)
285                        VALUES (?1, ?2)
286                        ON CONFLICT(canonical_name, alias) DO NOTHING
287                        "#,
288                    )
289                    .bind(&canonical_name)
290                    .bind(alias)
291                    .execute(&mut *connection)
292                    .await
293                    .map_err(|error| {
294                        TrackError::new(
295                            ErrorCode::ProjectWriteFailed,
296                            format!(
297                                "Could not save the alias {alias} for project {canonical_name}: {error}"
298                            ),
299                        )
300                    })?;
301                }
302
303                Ok(ProjectRecord {
304                    canonical_name,
305                    aliases: merged_aliases,
306                    metadata,
307                })
308            })
309        })
310    }
311}
312
313pub fn infer_project_metadata(project: &ProjectInfo) -> ProjectMetadata {
314    build_default_metadata(project)
315}
316
317async fn load_aliases(
318    connection: &mut sqlx::SqliteConnection,
319    canonical_name: &str,
320) -> Result<Vec<String>, TrackError> {
321    let rows = sqlx::query(
322        r#"
323        SELECT alias
324        FROM project_aliases
325        WHERE canonical_name = ?1
326        ORDER BY alias ASC
327        "#,
328    )
329    .bind(canonical_name)
330    .fetch_all(&mut *connection)
331    .await
332    .map_err(|error| {
333        TrackError::new(
334            ErrorCode::ProjectWriteFailed,
335            format!("Could not load project aliases for {canonical_name}: {error}"),
336        )
337    })?;
338
339    Ok(rows
340        .into_iter()
341        .map(|row| row.get::<String, _>("alias"))
342        .collect())
343}
344
345async fn ensure_aliases_are_available(
346    connection: &mut sqlx::SqliteConnection,
347    canonical_name: &str,
348    aliases: &[String],
349) -> Result<(), TrackError> {
350    for alias in aliases {
351        let row = sqlx::query(
352            r#"
353            SELECT canonical_name
354            FROM project_aliases
355            WHERE alias = ?1
356            "#,
357        )
358        .bind(alias)
359        .fetch_optional(&mut *connection)
360        .await
361        .map_err(|error| {
362            TrackError::new(
363                ErrorCode::ProjectWriteFailed,
364                format!("Could not verify whether alias {alias} is available: {error}"),
365            )
366        })?;
367
368        if let Some(row) = row {
369            let claimed_by = row.get::<String, _>("canonical_name");
370            if claimed_by != canonical_name {
371                return Err(TrackError::new(
372                    ErrorCode::InvalidProjectMetadata,
373                    format!("Project alias {alias} is already registered to project {claimed_by}."),
374                ));
375            }
376        }
377    }
378
379    Ok(())
380}
381
382fn build_default_metadata(project: &ProjectInfo) -> ProjectMetadata {
383    let fallback_file_url = format!("file://{}", project.path.to_string_lossy());
384    let git_url = read_origin_git_url(&project.path).unwrap_or_else(|| fallback_file_url.clone());
385    let repo_url = if git_url == fallback_file_url {
386        fallback_file_url
387    } else {
388        derive_repo_url(&git_url)
389    };
390    let base_branch =
391        infer_default_base_branch(&project.path).unwrap_or_else(|| DEFAULT_BASE_BRANCH.to_owned());
392
393    ProjectMetadata {
394        repo_url,
395        git_url,
396        base_branch,
397        description: None,
398    }
399}
400
401fn infer_default_base_branch(project_path: &Path) -> Option<String> {
402    let git_common_directory = resolve_git_common_directory(project_path)?;
403
404    read_symbolic_ref_branch(
405        &git_common_directory.join("refs/remotes/origin/HEAD"),
406        "refs/remotes/origin/",
407    )
408    .or_else(|| read_symbolic_ref_branch(&git_common_directory.join("HEAD"), "refs/heads/"))
409}
410
411fn read_origin_git_url(project_path: &Path) -> Option<String> {
412    let git_directory = resolve_git_directory(project_path)?;
413    let config = fs::read_to_string(git_directory.join("config")).ok()?;
414
415    let mut in_origin_section = false;
416    for raw_line in config.lines() {
417        let line = raw_line.trim();
418        if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
419            continue;
420        }
421
422        if line.starts_with('[') && line.ends_with(']') {
423            in_origin_section = line == "[remote \"origin\"]";
424            continue;
425        }
426
427        if !in_origin_section {
428            continue;
429        }
430
431        let Some((key, value)) = line.split_once('=') else {
432            continue;
433        };
434        if key.trim() != "url" {
435            continue;
436        }
437
438        let value = value.trim();
439        if value.is_empty() {
440            return None;
441        }
442
443        return Some(value.to_owned());
444    }
445
446    None
447}
448
449fn resolve_git_directory(project_path: &Path) -> Option<PathBuf> {
450    let git_marker = project_path.join(".git");
451    if git_marker.is_dir() {
452        return Some(git_marker);
453    }
454
455    if !git_marker.is_file() {
456        return None;
457    }
458
459    let gitdir_directive = fs::read_to_string(git_marker).ok()?;
460    let relative_path = gitdir_directive
461        .trim()
462        .strip_prefix("gitdir:")?
463        .trim()
464        .to_owned();
465
466    Some(project_path.join(relative_path))
467}
468
469fn resolve_git_common_directory(project_path: &Path) -> Option<PathBuf> {
470    let git_directory = resolve_git_directory(project_path)?;
471    let commondir_path = git_directory.join("commondir");
472    if !commondir_path.is_file() {
473        return Some(git_directory);
474    }
475
476    let relative = fs::read_to_string(commondir_path).ok()?;
477    Some(git_directory.join(relative.trim()))
478}
479
480fn read_symbolic_ref_branch(reference_path: &Path, prefix: &str) -> Option<String> {
481    let symbolic_ref = fs::read_to_string(reference_path).ok()?;
482    symbolic_ref
483        .trim()
484        .strip_prefix("ref:")?
485        .trim()
486        .strip_prefix(prefix)
487        .map(str::to_owned)
488}
489
490pub fn derive_repo_url(git_url: &str) -> String {
491    let git_url = git_url.trim();
492
493    if let Some(path) = git_url.strip_prefix("git@") {
494        if let Some((host, repo_path)) = path.split_once(':') {
495            return format!("https://{host}/{}", repo_path.trim_end_matches(".git"));
496        }
497    }
498
499    git_url
500        .trim_end_matches(".git")
501        .replace("ssh://git@", "https://")
502        .replace("ssh://", "https://")
503}
504
505#[cfg(test)]
506mod tests {
507    use tempfile::TempDir;
508
509    use super::{ProjectMetadata, ProjectRepository};
510    use crate::errors::ErrorCode;
511
512    fn metadata(description: &str) -> ProjectMetadata {
513        ProjectMetadata {
514            repo_url: "https://github.com/acme/project-a".to_owned(),
515            git_url: "git@github.com:acme/project-a.git".to_owned(),
516            base_branch: "main".to_owned(),
517            description: Some(description.to_owned()),
518        }
519    }
520
521    #[test]
522    fn upsert_project_preserves_existing_aliases_when_no_new_aliases_are_provided() {
523        let directory = TempDir::new().expect("tempdir should be created");
524        let repository = ProjectRepository::new(Some(directory.path().join("track.sqlite")))
525            .expect("project repository should resolve");
526
527        repository
528            .upsert_project_by_name("project-a", metadata("first"), vec!["legacy-a".to_owned()])
529            .expect("project should save");
530        let project = repository
531            .upsert_project_by_name("project-a", metadata("second"), Vec::new())
532            .expect("project should update");
533
534        assert_eq!(project.aliases, vec!["legacy-a".to_owned()]);
535        assert_eq!(project.metadata.description.as_deref(), Some("second"));
536    }
537
538    #[test]
539    fn upsert_project_unions_new_aliases_with_existing_aliases() {
540        let directory = TempDir::new().expect("tempdir should be created");
541        let repository = ProjectRepository::new(Some(directory.path().join("track.sqlite")))
542            .expect("project repository should resolve");
543
544        repository
545            .upsert_project_by_name("project-a", metadata("first"), vec!["legacy-a".to_owned()])
546            .expect("project should save");
547        let project = repository
548            .upsert_project_by_name(
549                "project-a",
550                metadata("second"),
551                vec!["new-a".to_owned(), "legacy-a".to_owned()],
552            )
553            .expect("project should update");
554
555        assert_eq!(
556            project.aliases,
557            vec!["legacy-a".to_owned(), "new-a".to_owned()]
558        );
559    }
560
561    #[test]
562    fn upsert_project_rejects_conflicting_alias_without_partial_writes() {
563        let directory = TempDir::new().expect("tempdir should be created");
564        let repository = ProjectRepository::new(Some(directory.path().join("track.sqlite")))
565            .expect("project repository should resolve");
566
567        repository
568            .upsert_project_by_name("project-a", metadata("first"), vec!["shared".to_owned()])
569            .expect("project a should save");
570        repository
571            .upsert_project_by_name("project-b", metadata("before"), Vec::new())
572            .expect("project b should save");
573
574        let error = repository
575            .upsert_project_by_name("project-b", metadata("after"), vec!["shared".to_owned()])
576            .expect_err("conflicting alias should fail");
577        assert_eq!(error.code, ErrorCode::InvalidProjectMetadata);
578
579        let project_b = repository
580            .get_project_by_name("project-b")
581            .expect("project b should still load");
582        assert!(project_b.aliases.is_empty());
583        assert_eq!(project_b.metadata.description.as_deref(), Some("before"));
584    }
585}