Skip to main content

git_slop/
model.rs

1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub(crate) enum Classification {
10    Source,
11    Test,
12    Docs,
13    Tool,
14    Workflow,
15    Config,
16    Data,
17    Generated,
18    Snapshot,
19    Fixture,
20    Vendored,
21    MigrationFixture,
22    Other,
23}
24
25impl Classification {
26    pub(crate) const ALL: [Self; 13] = [
27        Self::Source,
28        Self::Test,
29        Self::Docs,
30        Self::Tool,
31        Self::Workflow,
32        Self::Config,
33        Self::Data,
34        Self::Generated,
35        Self::Snapshot,
36        Self::Fixture,
37        Self::Vendored,
38        Self::MigrationFixture,
39        Self::Other,
40    ];
41
42    pub(crate) const fn as_str(self) -> &'static str {
43        match self {
44            Self::Source => "source",
45            Self::Test => "test",
46            Self::Docs => "docs",
47            Self::Tool => "tool",
48            Self::Workflow => "workflow",
49            Self::Config => "config",
50            Self::Data => "data",
51            Self::Generated => "generated",
52            Self::Snapshot => "snapshot",
53            Self::Fixture => "fixture",
54            Self::Vendored => "vendored",
55            Self::MigrationFixture => "migration_fixture",
56            Self::Other => "other",
57        }
58    }
59
60    pub(crate) fn is_valid(value: &str) -> bool {
61        Self::ALL
62            .iter()
63            .any(|candidate| candidate.as_str() == value)
64    }
65
66    pub(crate) fn values() -> Vec<&'static str> {
67        Self::ALL.iter().map(|value| value.as_str()).collect()
68    }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct RepoMetadata {
73    pub repo_name: String,
74    pub repo_root: String,
75    /// Stable, privacy-safe comparator identity. Remote-backed repositories
76    /// use normalized host/owner/name; local-only repositories use root commit.
77    pub repository_id: Option<String>,
78    pub repository_identity_source: Option<String>,
79    pub branch: Option<String>,
80    pub head_commit: Option<String>,
81    pub head_commit_timestamp: Option<String>,
82    pub git_remote_url: Option<String>,
83    pub is_shallow: bool,
84    pub detached_head: bool,
85    pub worktree_clean: bool,
86    pub staged_change_count: usize,
87    pub modified_tracked_file_count: usize,
88    pub untracked_file_count: usize,
89    pub worktree_state_digest: String,
90    pub analyzed_content_digest: Option<String>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
94#[serde(deny_unknown_fields)]
95pub struct ScopeIdentity {
96    pub mode: String,
97    pub path: Option<String>,
98    pub selected_path_count: usize,
99    pub selected_path_digest: String,
100}
101
102#[derive(Debug, Clone, Default, Serialize, Deserialize)]
103pub struct SkippedCounts {
104    pub ignored: usize,
105    pub missing: usize,
106    pub binary: usize,
107    pub undecodable: usize,
108}
109
110#[derive(Debug, Clone)]
111pub struct InventoryFile {
112    pub path: String,
113    pub bytes: usize,
114    pub lines: usize,
115    pub blank_lines: usize,
116    pub code_lines: usize,
117    pub comment_lines: usize,
118    pub language: String,
119    pub profile: String,
120    pub classification: String,
121    pub generated_from: Vec<String>,
122    pub generated_provenance: Value,
123    /// SHA-256 of the tracked raw bytes before decoding or newline normalization.
124    pub content_sha256: String,
125    pub text: String,
126    pub analysis_status: String,
127    pub skipped_reason: Option<String>,
128    pub symlink_metadata: Option<Value>,
129}
130
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct HistoryMetrics {
133    pub first_seen_timestamp: Option<i64>,
134    pub age_days: u64,
135    pub revisions_window: usize,
136    pub recency_weighted_commits: f64,
137    pub added_window: usize,
138    pub deleted_window: usize,
139    pub line_churn_window: usize,
140    pub token_churn_window: usize,
141    pub relative_churn_window: f64,
142    pub late_churn_spike: f64,
143    pub author_count_window: usize,
144    pub author_entropy: f64,
145    pub top_author_share: f64,
146    pub days_since_non_bot_edit: Option<u64>,
147    pub recent_maintainer_diversity: usize,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct CommitRecord {
152    pub commit: String,
153    pub timestamp: i64,
154    pub author: String,
155    pub paths: Vec<String>,
156    pub line_churn_by_path: BTreeMap<String, usize>,
157    pub change_set_size: usize,
158    pub change_kind: String,
159    pub calibration_weight: f64,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct FileAnalysis {
165    pub path: String,
166    pub bytes: usize,
167    pub lines: usize,
168    pub blank_lines: usize,
169    pub code_lines: usize,
170    pub comment_lines: usize,
171    pub language: String,
172    pub profile: String,
173    pub classification: String,
174    pub generated_from: Vec<String>,
175    pub generated_provenance: Value,
176    pub analysis_status: String,
177    pub skipped_reason: Option<String>,
178    pub symlink_metadata: Option<Value>,
179    pub has_inline_tests: bool,
180    pub tokens: usize,
181    pub context_band: String,
182    pub context_pressure: f64,
183    /// Stable content identity used to distinguish source changes from
184    /// history-only score movement during report comparison.
185    pub content_fingerprint: String,
186    /// Exact tracked-content identity, including binary and otherwise skipped records.
187    pub content_sha256: String,
188    #[serde(skip)]
189    pub structural_tokens: Vec<String>,
190    pub structural_token_count: usize,
191    pub top_structural_terms: Vec<String>,
192    pub structural_categories: Value,
193    pub age_days: u64,
194    pub revisions_window: usize,
195    pub recency_weighted_commits: f64,
196    pub added_window: usize,
197    pub deleted_window: usize,
198    pub churn_lines_window: usize,
199    pub line_churn_window: usize,
200    pub token_churn_window: usize,
201    pub relative_churn_window: f64,
202    pub late_churn_spike: f64,
203    pub author_count_window: usize,
204    pub author_entropy: f64,
205    pub top_author_share: f64,
206    pub days_since_non_bot_edit: Option<u64>,
207    pub recent_maintainer_diversity: usize,
208    pub age_pressure: f64,
209    pub revision_norm: f64,
210    pub relative_churn_norm: f64,
211    pub churn_pressure: f64,
212    pub slop_score: f64,
213    pub slop_band: String,
214    pub reason_codes: Vec<String>,
215    pub costs: Value,
216    pub overlays: Value,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct FolderAnalysis {
222    pub path: String,
223    pub descendant_file_count: usize,
224    pub direct_file_count: usize,
225    pub bytes: usize,
226    pub lines: usize,
227    pub tokens: usize,
228    pub direct_tokens: usize,
229    pub context_band: String,
230    pub health_band: String,
231    pub context_pressure: f64,
232    pub slop_score: f64,
233    pub slop_band: String,
234    pub reason_codes: Vec<String>,
235    pub top_file_path: String,
236    pub classification: String,
237    pub costs: Value,
238    pub overlays: Value,
239}
240
241#[derive(Debug, Clone, Default, Serialize, Deserialize)]
242pub struct OrganizationAnalysis {
243    pub organization_metrics: Value,
244    pub relationships: Value,
245    pub clusters: Value,
246    pub file_overlays: BTreeMap<String, Value>,
247    pub folder_overlays: BTreeMap<String, Value>,
248    pub top_structural_files: Vec<Value>,
249}
250
251#[derive(Debug, Clone)]
252pub struct Analysis {
253    pub output_root: PathBuf,
254    pub report_profile: String,
255    pub compression: String,
256    pub repo: RepoMetadata,
257    pub config: Value,
258    pub generated_at: String,
259    pub analyzed_revision_at: Option<String>,
260    pub skipped: SkippedCounts,
261    pub tracked_file_count: usize,
262    pub scope: ScopeIdentity,
263    pub files: Vec<FileAnalysis>,
264    pub folders: Vec<FolderAnalysis>,
265    pub organization: OrganizationAnalysis,
266    pub action_queue: Vec<Value>,
267    pub observation_feed: Vec<Value>,
268    pub diagnostics: Value,
269}
270
271#[derive(Debug, Clone)]
272pub struct FindResult {
273    pub report: Value,
274    pub report_json: PathBuf,
275    /// Conventional YAML path. The file exists only when output.yaml is true.
276    pub report_yaml: PathBuf,
277    pub summary_md: PathBuf,
278    pub health_md: PathBuf,
279    pub compressed_report: Option<PathBuf>,
280    pub terminal: String,
281    /// End-to-end analysis and report-publication elapsed time.
282    pub elapsed_ms: u128,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
286#[serde(deny_unknown_fields)]
287pub struct Finding {
288    pub path: String,
289    pub profile: String,
290    pub severity: String,
291    pub title: String,
292    pub message: String,
293    pub next_command: String,
294    pub slop_band: String,
295    pub context_band: String,
296    pub slop_score: f64,
297    pub tokens: usize,
298    pub reasons: Vec<String>,
299}
300
301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302#[serde(deny_unknown_fields)]
303pub struct HealthRollup {
304    pub file_band_counts: BTreeMap<String, usize>,
305    pub folder_band_counts: BTreeMap<String, usize>,
306    pub profile_rollups: Vec<Value>,
307    pub language_rollups: BTreeMap<String, Vec<Value>>,
308    pub file_distribution: Value,
309    pub folder_distribution: Value,
310    pub refactor_candidates: Vec<Value>,
311    pub watchlist: Vec<Value>,
312    pub findings: Vec<Finding>,
313}
314
315pub fn parent_folders(path: &str) -> Vec<String> {
316    let normalized = path.trim_matches('/');
317    let mut result = vec![".".to_string()];
318    let parts: Vec<&str> = normalized.split('/').collect();
319    if parts.len() <= 1 {
320        return result;
321    }
322    for index in 1..parts.len() {
323        result.push(parts[..index].join("/"));
324    }
325    result
326}
327
328pub fn top_level_root(path: &str) -> String {
329    path.trim_matches('/')
330        .split('/')
331        .next()
332        .filter(|part| !part.is_empty())
333        .unwrap_or(".")
334        .to_string()
335}