Skip to main content

git_same/domain/
repo_path_template.rs

1//! Repository path templating.
2
3use crate::types::OwnedRepo;
4use std::path::{Component, Path, PathBuf};
5
6/// Canonical renderer for workspace repository paths.
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RepoPathTemplate {
9    template: String,
10}
11
12impl RepoPathTemplate {
13    /// Create a new path template.
14    pub fn new(template: impl Into<String>) -> Self {
15        Self {
16            template: template.into(),
17        }
18    }
19
20    /// Returns the underlying template string.
21    pub fn as_str(&self) -> &str {
22        &self.template
23    }
24
25    /// Render a repository path from template placeholders.
26    pub fn render(&self, base_path: &Path, provider: &str, owner: &str, repo: &str) -> PathBuf {
27        let provider = sanitize_component(provider);
28        let owner = sanitize_component(owner);
29        let repo = sanitize_component(repo);
30        let rendered = self
31            .template
32            .replace("{provider}", &provider)
33            .replace("{org}", &owner)
34            .replace("{repo}", &repo);
35
36        base_path.join(rendered)
37    }
38
39    /// Render a repository path from an owned repository object.
40    pub fn render_owned_repo(&self, base_path: &Path, repo: &OwnedRepo, provider: &str) -> PathBuf {
41        self.render(base_path, provider, &repo.owner, &repo.repo.name)
42    }
43
44    /// Render from a full name (`org/repo`) when available.
45    pub fn render_full_name(
46        &self,
47        base_path: &Path,
48        provider: &str,
49        full_name: &str,
50    ) -> Option<PathBuf> {
51        let (owner, repo) = full_name.split_once('/')?;
52        Some(self.render(base_path, provider, owner, repo))
53    }
54
55    /// Expected scan depth for local repository traversal.
56    pub fn scan_depth(&self) -> usize {
57        let sample = self
58            .template
59            .replace("{provider}", "provider")
60            .replace("{org}", "org")
61            .replace("{repo}", "repo");
62
63        let depth = Path::new(&sample)
64            .components()
65            .filter(|c| matches!(c, Component::Normal(_)))
66            .count();
67
68        depth.max(1)
69    }
70}
71
72fn sanitize_component(value: &str) -> String {
73    let trimmed = value.trim();
74    if trimmed.is_empty() {
75        return "_".to_string();
76    }
77
78    let mut sanitized = trimmed
79        .replace(['/', '\\'], "_")
80        .replace("..", "__")
81        .trim()
82        .to_string();
83
84    if sanitized.is_empty() {
85        sanitized = "_".to_string();
86    }
87
88    sanitized
89}
90
91impl Default for RepoPathTemplate {
92    fn default() -> Self {
93        Self::new("{org}/{repo}")
94    }
95}
96
97#[cfg(test)]
98#[path = "repo_path_template_tests.rs"]
99mod tests;