Skip to main content

vibe_graph_ops/
project.rs

1//! Domain types for projects, repositories, and sources.
2
3use std::path::PathBuf;
4
5use file_format::FileFormat;
6use humansize::{format_size, DECIMAL};
7use serde::{Deserialize, Serialize};
8
9use crate::error::OpsResult;
10
11/// A project represents a collection of repositories.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Project {
14    /// Name of the project.
15    pub name: String,
16
17    /// Type of project source.
18    pub source: ProjectSource,
19
20    /// All repositories in this project.
21    pub repositories: Vec<Repository>,
22}
23
24/// Where the project originates from.
25///
26/// Note: Uses default externally tagged serde format for backward compatibility
27/// with existing `.self/project.json` files (e.g., `{"LocalPaths": {...}}`).
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub enum ProjectSource {
30    /// A GitHub organization.
31    GitHubOrg { organization: String },
32    /// A single GitHub repository.
33    GitHubRepo { owner: String, repo: String },
34    /// A local directory (single repo).
35    LocalPath { path: PathBuf },
36    /// Multiple local directories.
37    LocalPaths { paths: Vec<PathBuf> },
38}
39
40impl Project {
41    /// Create a new project for a GitHub organization.
42    pub fn github_org(organization: impl Into<String>) -> Self {
43        let org = organization.into();
44        Self {
45            name: org.clone(),
46            source: ProjectSource::GitHubOrg { organization: org },
47            repositories: vec![],
48        }
49    }
50
51    /// Create a new project for a single GitHub repository.
52    pub fn github_repo(owner: impl Into<String>, repo: impl Into<String>) -> Self {
53        let owner = owner.into();
54        let repo = repo.into();
55        Self {
56            name: repo.clone(),
57            source: ProjectSource::GitHubRepo { owner, repo },
58            repositories: vec![],
59        }
60    }
61
62    /// Create a new project from a local path.
63    pub fn local(path: PathBuf) -> Self {
64        let name = path
65            .file_name()
66            .map(|s| s.to_string_lossy().to_string())
67            .unwrap_or_else(|| "local".to_string());
68        Self {
69            name,
70            source: ProjectSource::LocalPath { path },
71            repositories: vec![],
72        }
73    }
74
75    /// Create a new project from multiple local paths.
76    pub fn local_paths(name: impl Into<String>, paths: Vec<PathBuf>) -> Self {
77        Self {
78            name: name.into(),
79            source: ProjectSource::LocalPaths { paths },
80            repositories: vec![],
81        }
82    }
83
84    /// Expand content for sources matching the filter predicate.
85    pub fn expand_content<F>(&mut self, filter_fn: F) -> OpsResult<()>
86    where
87        F: Fn(&Source) -> bool,
88    {
89        for repo in &mut self.repositories {
90            for source in &mut repo.sources {
91                if filter_fn(source) {
92                    source.content = std::fs::read_to_string(&source.path).ok();
93                }
94            }
95        }
96        Ok(())
97    }
98
99    /// Get total count of all sources across repositories.
100    pub fn total_sources(&self) -> usize {
101        self.repositories.iter().map(|r| r.sources.len()).sum()
102    }
103
104    /// Get total size of all sources.
105    pub fn total_size(&self) -> u64 {
106        self.repositories
107            .iter()
108            .flat_map(|r| &r.sources)
109            .filter_map(|s| s.size)
110            .sum()
111    }
112
113    /// Get human-readable total size.
114    pub fn human_total_size(&self) -> String {
115        format_size(self.total_size(), DECIMAL)
116    }
117}
118
119/// A repository within a project.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct Repository {
122    /// Repository name.
123    pub name: String,
124
125    /// Clone URL (for remote repos) or local path.
126    pub url: String,
127
128    /// Local path where the repository is checked out.
129    pub local_path: PathBuf,
130
131    /// All source files in this repository.
132    pub sources: Vec<Source>,
133}
134
135impl Repository {
136    /// Create a new repository.
137    pub fn new(name: impl Into<String>, url: impl Into<String>, local_path: PathBuf) -> Self {
138        Self {
139            name: name.into(),
140            url: url.into(),
141            local_path,
142            sources: vec![],
143        }
144    }
145
146    /// Get total size of all sources in this repository.
147    pub fn total_size(&self) -> u64 {
148        self.sources.iter().filter_map(|s| s.size).sum()
149    }
150
151    /// Get human-readable total size.
152    pub fn human_total_size(&self) -> String {
153        format_size(self.total_size(), DECIMAL)
154    }
155}
156
157/// A source file within a repository.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct Source {
160    /// Absolute path to the file.
161    pub path: PathBuf,
162
163    /// Path relative to the repository root.
164    pub relative_path: String,
165
166    /// Detected file format.
167    #[serde(with = "file_format_serde")]
168    pub format: FileFormat,
169
170    /// File size in bytes.
171    pub size: Option<u64>,
172
173    /// File content (populated on demand).
174    pub content: Option<String>,
175}
176
177impl Source {
178    /// Create a new source from a file path.
179    pub fn from_path(path: PathBuf, repo_root: &PathBuf) -> OpsResult<Self> {
180        let relative_path = path
181            .strip_prefix(repo_root)
182            .map(|p| p.to_string_lossy().to_string())
183            .unwrap_or_else(|_| path.to_string_lossy().to_string());
184
185        let format = FileFormat::from_file(&path).unwrap_or(FileFormat::ArbitraryBinaryData);
186        let size = std::fs::metadata(&path).ok().map(|m| m.len());
187
188        Ok(Self {
189            path,
190            relative_path,
191            format,
192            size,
193            content: None,
194        })
195    }
196
197    /// Get human-readable size.
198    pub fn human_size(&self) -> String {
199        self.size
200            .map(|s| format_size(s, DECIMAL))
201            .unwrap_or_else(|| "unknown".to_string())
202    }
203
204    /// Check if this source appears to be a text file.
205    pub fn is_text(&self) -> bool {
206        matches!(
207            self.format,
208            FileFormat::ArbitraryBinaryData | FileFormat::PlainText
209        )
210    }
211
212    /// Get the file extension if available.
213    pub fn extension(&self) -> Option<&str> {
214        self.path.extension().and_then(|e| e.to_str())
215    }
216}
217
218/// Custom serde for FileFormat.
219mod file_format_serde {
220    use file_format::FileFormat;
221    use serde::{Deserialize, Deserializer, Serialize, Serializer};
222
223    pub fn serialize<S>(format: &FileFormat, serializer: S) -> Result<S::Ok, S::Error>
224    where
225        S: Serializer,
226    {
227        format.to_string().serialize(serializer)
228    }
229
230    pub fn deserialize<'de, D>(deserializer: D) -> Result<FileFormat, D::Error>
231    where
232        D: Deserializer<'de>,
233    {
234        let s = String::deserialize(deserializer)?;
235        Ok(if s == "Plain Text" {
236            FileFormat::PlainText
237        } else {
238            FileFormat::ArbitraryBinaryData
239        })
240    }
241}