flake_edit/
channel.rs

1//! Channel-based update logic for repos like nixpkgs, home-manager, and nix-darwin.
2//!
3//! These repos use branches (e.g., `nixos-24.11`, `nixpkgs-unstable`) instead of
4//! semver tags for versioning.
5
6use crate::api::{Branches, branch_exists, get_branches};
7
8/// Update strategy for a given input.
9#[derive(Debug, Clone, PartialEq)]
10pub enum UpdateStrategy {
11    /// Standard semver tag-based updates (most repos)
12    SemverTags,
13    /// nixpkgs channel-based updates (nixos-YY.MM, nixpkgs-YY.MM)
14    NixpkgsChannel,
15    /// home-manager channel-based updates (release-YY.MM)
16    HomeManagerChannel,
17    /// nix-darwin channel-based updates (nix-darwin-YY.MM)
18    NixDarwinChannel,
19}
20
21/// Detected channel type from a ref string.
22#[derive(Debug, Clone, PartialEq)]
23pub enum ChannelType {
24    /// nixos-YY.MM (e.g., nixos-24.11)
25    NixosStable { year: u32, month: u32 },
26    /// nixpkgs-YY.MM (e.g., nixpkgs-24.11)
27    NixpkgsStable { year: u32, month: u32 },
28    /// Unstable/rolling branches (nixos-unstable, nixpkgs-unstable, master, main)
29    Unstable,
30    /// home-manager release-YY.MM
31    HomeManagerRelease { year: u32, month: u32 },
32    /// nix-darwin-YY.MM
33    NixDarwinStable { year: u32, month: u32 },
34    /// Bare version YY.MM (no prefix)
35    BareVersion { year: u32, month: u32 },
36    /// Not a recognized channel pattern
37    Unknown,
38}
39
40impl ChannelType {
41    /// Returns true if this is an unstable/rolling channel that shouldn't be updated.
42    pub fn is_unstable(&self) -> bool {
43        matches!(self, ChannelType::Unstable)
44    }
45
46    /// Returns the version tuple for comparison, if applicable.
47    pub fn version(&self) -> Option<(u32, u32)> {
48        match self {
49            ChannelType::NixosStable { year, month }
50            | ChannelType::NixpkgsStable { year, month }
51            | ChannelType::HomeManagerRelease { year, month }
52            | ChannelType::NixDarwinStable { year, month }
53            | ChannelType::BareVersion { year, month } => Some((*year, *month)),
54            _ => None,
55        }
56    }
57
58    /// Returns the branch prefix for finding similar channels.
59    pub fn prefix(&self) -> Option<&'static str> {
60        match self {
61            ChannelType::NixosStable { .. } => Some("nixos-"),
62            ChannelType::NixpkgsStable { .. } => Some("nixpkgs-"),
63            ChannelType::HomeManagerRelease { .. } => Some("release-"),
64            ChannelType::NixDarwinStable { .. } => Some("nix-darwin-"),
65            ChannelType::BareVersion { .. } => Some(""),
66            _ => None,
67        }
68    }
69}
70
71/// Detect the update strategy based on owner/repo.
72pub fn detect_strategy(owner: &str, repo: &str) -> UpdateStrategy {
73    match (owner.to_lowercase().as_str(), repo.to_lowercase().as_str()) {
74        ("nixos", "nixpkgs") => UpdateStrategy::NixpkgsChannel,
75        ("nix-community", "home-manager") => UpdateStrategy::HomeManagerChannel,
76        ("lnl7", "nix-darwin") | ("nix-community", "nix-darwin") => {
77            UpdateStrategy::NixDarwinChannel
78        }
79        _ => UpdateStrategy::SemverTags,
80    }
81}
82
83/// Parse a ref string to determine its channel type.
84pub fn parse_channel_ref(ref_str: &str) -> ChannelType {
85    let ref_str = ref_str.strip_prefix("refs/heads/").unwrap_or(ref_str);
86
87    if ref_str == "nixos-unstable" || ref_str == "nixpkgs-unstable" {
88        return ChannelType::Unstable;
89    }
90
91    // master/main are unstable branches for home-manager and nix-darwin
92    if ref_str == "master" || ref_str == "main" {
93        return ChannelType::Unstable;
94    }
95
96    if let Some(version) = ref_str.strip_prefix("nixos-")
97        && let Some((year, month)) = parse_version(version)
98    {
99        return ChannelType::NixosStable { year, month };
100    }
101
102    if let Some(version) = ref_str.strip_prefix("nixpkgs-")
103        && let Some((year, month)) = parse_version(version)
104    {
105        return ChannelType::NixpkgsStable { year, month };
106    }
107
108    if let Some(version) = ref_str.strip_prefix("release-")
109        && let Some((year, month)) = parse_version(version)
110    {
111        return ChannelType::HomeManagerRelease { year, month };
112    }
113
114    if let Some(version) = ref_str.strip_prefix("nix-darwin-")
115        && let Some((year, month)) = parse_version(version)
116    {
117        return ChannelType::NixDarwinStable { year, month };
118    }
119
120    // Try bare version YY.MM
121    if let Some((year, month)) = parse_version(ref_str) {
122        return ChannelType::BareVersion { year, month };
123    }
124
125    ChannelType::Unknown
126}
127
128/// Parse a version string like "24.11" into (year, month).
129fn parse_version(version: &str) -> Option<(u32, u32)> {
130    let parts: Vec<&str> = version.split('.').collect();
131    if parts.len() == 2 {
132        let year = parts[0].parse::<u32>().ok()?;
133        let month = parts[1].parse::<u32>().ok()?;
134        // Sanity check: year should be reasonable (20-99), month should be valid
135        if (20..=99).contains(&year) && (month == 5 || month == 11) {
136            return Some((year, month));
137        }
138    }
139    None
140}
141
142/// Find the latest channel branch that matches the current channel type.
143///
144/// Returns `None` if:
145/// - The current ref is unstable (should not be updated)
146/// - The current ref is not a recognized channel
147/// - No newer channel exists
148pub fn find_latest_channel(
149    current_ref: &str,
150    owner: &str,
151    repo: &str,
152    domain: Option<&str>,
153) -> Option<String> {
154    let current_channel = parse_channel_ref(current_ref);
155
156    // Don't update unstable channels
157    if current_channel.is_unstable() {
158        tracing::debug!("Skipping update for unstable channel: {}", current_ref);
159        return None;
160    }
161
162    // Only update recognized channel patterns
163    let prefix = current_channel.prefix()?;
164    let current_version = current_channel.version()?;
165
166    // Try targeted approach first (much faster for repos with many branches like nixpkgs)
167    if let Some(latest) = find_latest_channel_targeted(prefix, current_version, owner, repo, domain)
168    {
169        if latest != current_ref {
170            return Some(latest);
171        } else {
172            tracing::debug!("{} is already on the latest channel", current_ref);
173            return None;
174        }
175    }
176
177    // Fallback: fetch all branches (for Gitea/Forgejo or if targeted fails)
178    tracing::debug!("Targeted lookup failed, falling back to listing all branches");
179    let branches = match get_branches(repo, owner, domain) {
180        Ok(b) => b,
181        Err(e) => {
182            tracing::error!("Failed to fetch branches: {}", e);
183            return None;
184        }
185    };
186
187    // Find all channels matching the same prefix and pick the latest
188    let latest = find_latest_matching_branch(&branches, prefix, current_version);
189
190    if let Some(ref latest_branch) = latest
191        && latest_branch == current_ref
192    {
193        tracing::debug!("{} is already on the latest channel", current_ref);
194        return None;
195    }
196
197    latest
198}
199
200/// Generate candidate channel versions from current to ~5 years in the future.
201/// Returns candidates from NEWEST to OLDEST for early exit optimization.
202fn generate_candidate_channels(prefix: &str, current_version: (u32, u32)) -> Vec<String> {
203    let (current_year, current_month) = current_version;
204    let mut candidates = Vec::new();
205
206    // Generate candidates for the next ~5 years (10 releases)
207    // NixOS releases in May (05) and November (11)
208    let mut year = current_year;
209    let mut month = current_month;
210
211    for _ in 0..10 {
212        // Move to next release
213        if month == 5 {
214            month = 11;
215        } else {
216            month = 5;
217            year += 1;
218        }
219
220        candidates.push(format!("{}{}.{:02}", prefix, year, month));
221    }
222
223    // Reverse so we check newest first (for early exit)
224    candidates.reverse();
225    candidates
226}
227
228/// Try to find the latest channel using targeted branch existence checks.
229/// Returns None if no candidates exist (caller should fall back to listing).
230fn find_latest_channel_targeted(
231    prefix: &str,
232    current_version: (u32, u32),
233    owner: &str,
234    repo: &str,
235    domain: Option<&str>,
236) -> Option<String> {
237    let candidates = generate_candidate_channels(prefix, current_version);
238
239    tracing::debug!(
240        "Checking candidate channels (newest first): {:?}",
241        candidates
242    );
243
244    // Check from newest to oldest, return first match (will be the newest)
245    for candidate in &candidates {
246        tracing::debug!("Checking if branch exists: {}", candidate);
247        if branch_exists(repo, owner, candidate, domain) {
248            tracing::debug!("Found existing channel: {}", candidate);
249            return Some(candidate.clone());
250        }
251    }
252
253    // No newer channel found, check if current version exists
254    let current_branch = format!("{}{}.{:02}", prefix, current_version.0, current_version.1);
255    tracing::debug!("No newer channel, checking current: {}", current_branch);
256    if branch_exists(repo, owner, &current_branch, domain) {
257        return Some(current_branch);
258    }
259
260    None
261}
262
263/// Find the latest branch matching a given prefix that is newer than current_version.
264fn find_latest_matching_branch(
265    branches: &Branches,
266    prefix: &str,
267    current_version: (u32, u32),
268) -> Option<String> {
269    let mut best: Option<(u32, u32, String)> = None;
270
271    for branch_name in &branches.names {
272        // Check if this branch matches our prefix
273        if let Some(version_str) = branch_name.strip_prefix(prefix) {
274            // Skip unstable variants
275            if version_str == "unstable" {
276                continue;
277            }
278
279            if let Some((year, month)) = parse_version(version_str) {
280                // Only consider versions >= current
281                if (year, month) >= current_version {
282                    match &best {
283                        None => {
284                            best = Some((year, month, branch_name.clone()));
285                        }
286                        Some((best_year, best_month, _)) => {
287                            if (year, month) > (*best_year, *best_month) {
288                                best = Some((year, month, branch_name.clone()));
289                            }
290                        }
291                    }
292                }
293            }
294        }
295    }
296
297    best.map(|(_, _, name)| name)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_detect_strategy() {
306        assert_eq!(
307            detect_strategy("nixos", "nixpkgs"),
308            UpdateStrategy::NixpkgsChannel
309        );
310        assert_eq!(
311            detect_strategy("NixOS", "nixpkgs"),
312            UpdateStrategy::NixpkgsChannel
313        );
314        assert_eq!(
315            detect_strategy("nix-community", "home-manager"),
316            UpdateStrategy::HomeManagerChannel
317        );
318        assert_eq!(
319            detect_strategy("LnL7", "nix-darwin"),
320            UpdateStrategy::NixDarwinChannel
321        );
322        assert_eq!(
323            detect_strategy("nix-community", "nix-darwin"),
324            UpdateStrategy::NixDarwinChannel
325        );
326        assert_eq!(
327            detect_strategy("some-user", "some-repo"),
328            UpdateStrategy::SemverTags
329        );
330    }
331
332    #[test]
333    fn test_parse_channel_ref_nixos() {
334        assert_eq!(
335            parse_channel_ref("nixos-24.11"),
336            ChannelType::NixosStable {
337                year: 24,
338                month: 11
339            }
340        );
341        assert_eq!(
342            parse_channel_ref("nixos-25.05"),
343            ChannelType::NixosStable { year: 25, month: 5 }
344        );
345        assert_eq!(parse_channel_ref("nixos-unstable"), ChannelType::Unstable);
346    }
347
348    #[test]
349    fn test_parse_channel_ref_nixpkgs() {
350        assert_eq!(
351            parse_channel_ref("nixpkgs-24.11"),
352            ChannelType::NixpkgsStable {
353                year: 24,
354                month: 11
355            }
356        );
357        assert_eq!(parse_channel_ref("nixpkgs-unstable"), ChannelType::Unstable);
358    }
359
360    #[test]
361    fn test_parse_channel_ref_home_manager() {
362        assert_eq!(
363            parse_channel_ref("release-24.11"),
364            ChannelType::HomeManagerRelease {
365                year: 24,
366                month: 11
367            }
368        );
369        assert_eq!(parse_channel_ref("master"), ChannelType::Unstable);
370    }
371
372    #[test]
373    fn test_parse_channel_ref_nix_darwin() {
374        assert_eq!(
375            parse_channel_ref("nix-darwin-24.11"),
376            ChannelType::NixDarwinStable {
377                year: 24,
378                month: 11
379            }
380        );
381        assert_eq!(parse_channel_ref("main"), ChannelType::Unstable);
382    }
383
384    #[test]
385    fn test_parse_channel_ref_bare_version() {
386        assert_eq!(
387            parse_channel_ref("24.11"),
388            ChannelType::BareVersion {
389                year: 24,
390                month: 11
391            }
392        );
393        assert_eq!(
394            parse_channel_ref("25.05"),
395            ChannelType::BareVersion { year: 25, month: 5 }
396        );
397    }
398
399    #[test]
400    fn test_parse_channel_ref_with_refs_heads_prefix() {
401        assert_eq!(
402            parse_channel_ref("refs/heads/nixos-24.11"),
403            ChannelType::NixosStable {
404                year: 24,
405                month: 11
406            }
407        );
408    }
409
410    #[test]
411    fn test_parse_channel_ref_unknown() {
412        assert_eq!(parse_channel_ref("v1.0.0"), ChannelType::Unknown);
413        assert_eq!(parse_channel_ref("nixos-invalid"), ChannelType::Unknown);
414        assert_eq!(parse_channel_ref("feature-branch"), ChannelType::Unknown);
415    }
416
417    #[test]
418    fn test_channel_type_is_unstable() {
419        assert!(ChannelType::Unstable.is_unstable());
420        assert!(parse_channel_ref("master").is_unstable());
421        assert!(parse_channel_ref("main").is_unstable());
422        assert!(parse_channel_ref("nixos-unstable").is_unstable());
423        assert!(
424            !ChannelType::NixosStable {
425                year: 24,
426                month: 11
427            }
428            .is_unstable()
429        );
430    }
431
432    #[test]
433    fn test_find_latest_matching_branch() {
434        let branches = Branches {
435            names: vec![
436                "nixos-23.11".to_string(),
437                "nixos-24.05".to_string(),
438                "nixos-24.11".to_string(),
439                "nixos-unstable".to_string(),
440                "master".to_string(),
441            ],
442        };
443
444        // Should find 24.11 as latest when on 24.05
445        let result = find_latest_matching_branch(&branches, "nixos-", (24, 5));
446        assert_eq!(result, Some("nixos-24.11".to_string()));
447
448        // Should return current when already on latest
449        let result = find_latest_matching_branch(&branches, "nixos-", (24, 11));
450        assert_eq!(result, Some("nixos-24.11".to_string()));
451
452        // Should return None when on a version newer than anything available
453        let result = find_latest_matching_branch(&branches, "nixos-", (25, 5));
454        assert_eq!(result, None);
455    }
456
457    #[test]
458    fn test_generate_candidate_channels() {
459        // Starting from 24.05: 24.11, 25.05, 25.11, ..., 29.05 (10 items)
460        // Reversed: 29.05, 28.11, ..., 24.11
461        let candidates = generate_candidate_channels("nixos-", (24, 5));
462        assert_eq!(candidates.len(), 10);
463        // Newest first
464        assert_eq!(candidates[0], "nixos-29.05");
465        // Oldest last
466        assert_eq!(candidates[9], "nixos-24.11");
467
468        // Starting from 24.11: 25.05, 25.11, ..., 29.11 (10 items)
469        // Reversed: 29.11, 29.05, ..., 25.05
470        let candidates = generate_candidate_channels("nixpkgs-", (24, 11));
471        assert_eq!(candidates[0], "nixpkgs-29.11"); // newest
472        assert_eq!(candidates[9], "nixpkgs-25.05"); // oldest
473    }
474}