tga 10.3.0

Developer productivity analytics — git commit collection, classification, and reporting
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! External developer aliases file support.
//!
//! Loads developer identity records from a standalone YAML file using
//! the same schema as the Python predecessor (`gitflow-analytics`).
//! This lets large teams (hundreds of engineers) share a single canonical
//! aliases file across multiple project configs instead of bloating each
//! config with inline `developer_aliases` entries.
//!
//! # File format
//!
//! ```yaml
//! developers:
//!   - name: "John Doe"
//!     primary_email: "john.doe@company.com"
//!     aliases:
//!       - "jdoe@gmail.com"
//!       - "John D."
//!     github_username: "jdoe"      # optional
//!     confidence: 1.0              # optional, defaults to 1.0
//!     reasoning: ""                # optional, for LLM-generated aliases
//! ```
//!
//! Use [`AliasFile::load`] to parse such a file from disk, then
//! [`AliasFile::to_alias_map`] to convert it into the
//! `HashMap<canonical_name, Vec<aliases>>` form consumed by
//! [`crate::collect::identity::resolver::IdentityResolver::from_alias_map`].

use std::collections::HashMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::core::config::{expand_path_with, home_dir};
use crate::core::errors::{Result, TgaError};

/// A single developer identity record in an external aliases file.
///
/// Schema-compatible with the Python predecessor's alias YAML format so
/// alias files generated by `gitflow-analytics` can be consumed unchanged.
/// `#[non_exhaustive]` (#137): outside this crate, build one with
/// [`DeveloperAliasEntry::new`].
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct DeveloperAliasEntry {
    /// Canonical display name (e.g. `"John Doe"`).
    pub name: String,

    /// Primary / canonical email address.
    pub primary_email: String,

    /// All alternative emails, git names, and username strings that
    /// should resolve to this developer.
    #[serde(default)]
    pub aliases: Vec<String>,

    /// Optional GitHub username.
    #[serde(default)]
    pub github_username: Option<String>,

    /// Confidence score (1.0 = manually verified, < 1.0 = LLM-suggested).
    #[serde(default = "default_confidence")]
    pub confidence: f64,

    /// Human-readable reasoning for LLM-generated aliases.
    #[serde(default)]
    pub reasoning: String,
}

impl DeveloperAliasEntry {
    /// A manually verified entry: no aliases, no GitHub username, confidence
    /// `1.0` and an empty reasoning — the values a YAML entry naming only
    /// `name` and `primary_email` deserializes to.
    pub fn new(name: impl Into<String>, primary_email: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            primary_email: primary_email.into(),
            aliases: Vec::new(),
            github_username: None,
            confidence: default_confidence(),
            reasoning: String::new(),
        }
    }
}

fn default_confidence() -> f64 {
    1.0
}

/// Top-level structure of an external aliases file.
///
/// `#[non_exhaustive]` (#137): outside this crate, start from
/// [`AliasFile::default`].
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AliasFile {
    /// All developer entries declared in the file.
    pub developers: Vec<DeveloperAliasEntry>,
}

impl AliasFile {
    /// Load an aliases file from disk.
    ///
    /// Supports leading `~` home-directory expansion in `path`.
    ///
    /// # Errors
    ///
    /// - [`TgaError::IoError`] if the file cannot be read.
    /// - [`TgaError::SerdeYamlError`] if YAML parsing fails.
    /// - [`TgaError::ConfigError`] if the parsed file is structurally
    ///   valid YAML but missing required fields.
    pub fn load(path: &Path) -> Result<Self> {
        // #5313: read $HOME here so `load_with_home` stays testable without it.
        Self::load_with_home(path, home_dir().as_deref())
    }

    /// [`AliasFile::load`], with the home directory used for `~` expansion
    /// supplied explicitly.
    ///
    /// Why (#5313): proving that `load` expands a leading `~` before reading
    /// the file used to require pointing `HOME` at a temp directory — `unsafe`
    /// under the 2024 edition, and a race against every other test thread,
    /// including the sibling `database_path_tilde_expands_and_is_absolute`
    /// which mutated the same variable.
    /// What: identical to [`AliasFile::load`] except that `home` replaces the
    /// `$HOME` read.
    /// Test: `alias_file_path_expansion`.
    ///
    /// # Errors
    ///
    /// Same as [`AliasFile::load`].
    pub(crate) fn load_with_home(path: &Path, home: Option<&Path>) -> Result<Self> {
        let resolved = expand_path_with(path, home);
        tracing::debug!(path = %resolved.display(), "loading external aliases file");
        let text = std::fs::read_to_string(&resolved).map_err(|e| {
            TgaError::ConfigError(format!(
                "failed to read aliases file {}: {e}",
                resolved.display()
            ))
        })?;
        let parsed: AliasFile = serde_yaml::from_str(&text)?;
        Ok(parsed)
    }

    /// Convert into the `HashMap<canonical_name, Vec<aliases>>` form
    /// consumed by `IdentityResolver::from_alias_map`.
    ///
    /// The `primary_email` is prepended to each entry's alias list so it
    /// is also registered as a lookup key. `github_username`, when
    /// present and non-empty, is appended as well. Duplicates within an
    /// entry's combined alias list are removed while preserving order.
    pub fn to_alias_map(&self) -> HashMap<String, Vec<String>> {
        let mut out: HashMap<String, Vec<String>> = HashMap::new();
        for dev in &self.developers {
            let mut combined: Vec<String> = Vec::with_capacity(dev.aliases.len() + 2);
            if !dev.primary_email.is_empty() {
                combined.push(dev.primary_email.clone());
            }
            for a in &dev.aliases {
                combined.push(a.clone());
            }
            if let Some(gh) = &dev.github_username {
                if !gh.is_empty() {
                    combined.push(gh.clone());
                }
            }
            // Dedupe while preserving order.
            let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
            combined.retain(|s| seen.insert(s.to_lowercase()));
            out.insert(dev.name.clone(), combined);
        }
        out
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_yaml() -> &'static str {
        r#"
developers:
  - name: "John Doe"
    primary_email: "john.doe@company.com"
    aliases:
      - "jdoe@gmail.com"
      - "john.doe@oldcompany.com"
      - "John D."
    github_username: "jdoe"
    confidence: 1.0
    reasoning: ""

  - name: "Alice Smith"
    primary_email: "alice@company.com"
    aliases:
      - "alice.smith@personal.com"
      - "asmith"
"#
    }

    #[test]
    fn alias_file_loads_from_yaml() {
        let parsed: AliasFile = serde_yaml::from_str(sample_yaml()).expect("parse");
        assert_eq!(parsed.developers.len(), 2);
        assert_eq!(parsed.developers[0].name, "John Doe");
        assert_eq!(parsed.developers[0].primary_email, "john.doe@company.com");
        assert_eq!(parsed.developers[0].aliases.len(), 3);
        assert_eq!(
            parsed.developers[0].github_username.as_deref(),
            Some("jdoe")
        );
        assert!((parsed.developers[0].confidence - 1.0).abs() < f64::EPSILON);
        // Alice has no github_username; should default to None.
        assert_eq!(parsed.developers[1].github_username, None);
        // Confidence defaults to 1.0 when absent.
        assert!((parsed.developers[1].confidence - 1.0).abs() < f64::EPSILON);
    }

    #[test]
    fn alias_file_to_map_includes_primary_email() {
        let parsed: AliasFile = serde_yaml::from_str(sample_yaml()).expect("parse");
        let map = parsed.to_alias_map();
        let john = map.get("John Doe").expect("John Doe present");
        assert!(
            john.iter().any(|s| s == "john.doe@company.com"),
            "primary_email should appear in alias list: {john:?}"
        );
        assert!(john.iter().any(|s| s == "jdoe@gmail.com"));
        assert!(john.iter().any(|s| s == "John D."));
        assert!(john.iter().any(|s| s == "jdoe"));
    }

    #[test]
    fn alias_file_to_map_dedupes_case_insensitive() {
        let yaml = r#"
developers:
  - name: "John Doe"
    primary_email: "john@example.com"
    aliases:
      - "john@example.com"
      - "JOHN@example.com"
      - "jdoe"
"#;
        let parsed: AliasFile = serde_yaml::from_str(yaml).expect("parse");
        let map = parsed.to_alias_map();
        let john = map.get("John Doe").expect("John Doe present");
        // primary_email + "jdoe" only; duplicate emails removed.
        assert_eq!(john.len(), 2, "expected 2 unique entries, got {john:?}");
    }

    #[test]
    fn alias_file_minimal_entry() {
        // Only required fields: name and primary_email.
        let yaml = r#"
developers:
  - name: "Solo Dev"
    primary_email: "solo@example.com"
"#;
        let parsed: AliasFile = serde_yaml::from_str(yaml).expect("parse");
        assert_eq!(parsed.developers.len(), 1);
        assert!(parsed.developers[0].aliases.is_empty());
        assert_eq!(parsed.developers[0].github_username, None);
        assert_eq!(parsed.developers[0].reasoning, "");
    }

    /// Why: `load` must expand a leading `~` before reading, so an aliases
    /// file configured as `~/aliases.yaml` is found.
    /// What: write the sample file into a unique temp directory, pass that
    /// directory as the home, and load `~/aliases.yaml`.
    /// Test: this test. #5313: it used to point the process-wide `HOME` at the
    /// temp directory, which is `unsafe` under the 2024 edition and raced
    /// `database_path_tilde_expands_and_is_absolute`, the other `HOME` mutator
    /// in this test binary. The home directory is now an argument.
    #[test]
    fn alias_file_path_expansion() {
        let unique = format!(
            "tga-alias-test-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        );
        let tmp = std::env::temp_dir().join(unique);
        std::fs::create_dir_all(&tmp).expect("create tmp");
        std::fs::write(tmp.join("aliases.yaml"), sample_yaml()).expect("write");

        let parsed = AliasFile::load_with_home(Path::new("~/aliases.yaml"), Some(&tmp))
            .expect("load via tilde");
        assert_eq!(parsed.developers.len(), 2);

        let _ = std::fs::remove_dir_all(&tmp);
    }
}

#[cfg(test)]
mod merge_tests {
    //! Integration-style tests for [`crate::core::config::Config::resolved_alias_map`]
    //! verifying inline + external merge semantics.

    use std::collections::HashMap;

    use crate::core::config::Config;

    fn unique_dir(label: &str) -> std::path::PathBuf {
        let unique = format!(
            "tga-alias-merge-{label}-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        );
        let d = std::env::temp_dir().join(unique);
        std::fs::create_dir_all(&d).expect("create tmp");
        d
    }

    #[test]
    fn alias_file_merge_overrides_inline() {
        let tmp = unique_dir("override");
        let external = r#"
developers:
  - name: "John Doe"
    primary_email: "john.new@company.com"
    aliases:
      - "john.alias@company.com"
"#;
        let aliases_path = tmp.join("aliases.yaml");
        std::fs::write(&aliases_path, external).expect("write aliases");

        let mut inline: HashMap<String, Vec<String>> = HashMap::new();
        inline.insert(
            "John Doe".to_string(),
            vec!["john.OLD@company.com".to_string()],
        );

        let cfg = Config {
            developer_aliases: inline,
            aliases_file: Some(aliases_path.to_string_lossy().into_owned()),
            ..Default::default()
        };

        let map = cfg.resolved_alias_map(None).expect("resolve");
        let john = map.get("John Doe").expect("john present");
        // External wins: inline old email should NOT be present.
        assert!(
            !john.iter().any(|s| s == "john.OLD@company.com"),
            "external entry should override inline, got {john:?}"
        );
        assert!(john.iter().any(|s| s == "john.new@company.com"));
        assert!(john.iter().any(|s| s == "john.alias@company.com"));

        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn alias_file_merge_additive() {
        let tmp = unique_dir("additive");
        let external = r#"
developers:
  - name: "Bob"
    primary_email: "bob@example.com"
"#;
        let aliases_path = tmp.join("aliases.yaml");
        std::fs::write(&aliases_path, external).expect("write aliases");

        let mut inline: HashMap<String, Vec<String>> = HashMap::new();
        inline.insert("Alice".to_string(), vec!["alice@example.com".to_string()]);

        let cfg = Config {
            developer_aliases: inline,
            aliases_file: Some(aliases_path.to_string_lossy().into_owned()),
            ..Default::default()
        };

        let map = cfg.resolved_alias_map(None).expect("resolve");
        assert!(map.contains_key("Alice"), "inline-only entry preserved");
        assert!(map.contains_key("Bob"), "external-only entry added");
        assert_eq!(map.len(), 2);

        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn alias_file_missing_file_errors() {
        let cfg = Config {
            aliases_file: Some("/nonexistent/path/to/aliases.yaml".to_string()),
            ..Default::default()
        };
        let err = cfg.resolved_alias_map(None).unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("aliases_file"),
            "error should mention aliases_file: {msg}"
        );
    }

    #[test]
    fn alias_file_relative_to_config_dir() {
        let tmp = unique_dir("reldir");
        let external = r#"
developers:
  - name: "Rel Person"
    primary_email: "rel@example.com"
"#;
        std::fs::write(tmp.join("aliases.yaml"), external).expect("write");

        let cfg = Config {
            aliases_file: Some("./aliases.yaml".to_string()),
            ..Default::default()
        };

        let map = cfg.resolved_alias_map(Some(&tmp)).expect("resolve");
        assert!(map.contains_key("Rel Person"));

        let _ = std::fs::remove_dir_all(&tmp);
    }
}