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 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 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}