1use super::api::{ApiError, Branches, ForgeClient};
7
8#[derive(Debug, Clone, PartialEq)]
10pub(crate) enum UpdateStrategy {
11 SemverTags,
13 NixpkgsChannel,
15 HomeManagerChannel,
17 NixDarwinChannel,
19}
20
21#[derive(Debug, Clone, PartialEq)]
23pub(crate) enum ChannelType {
24 NixosStable { year: u32, month: u32 },
26 NixpkgsStable { year: u32, month: u32 },
28 Unstable,
30 HomeManagerRelease { year: u32, month: u32 },
32 NixDarwinStable { year: u32, month: u32 },
34 BareVersion { year: u32, month: u32 },
36 Unknown,
38}
39
40impl ChannelType {
41 pub(crate) fn is_unstable(&self) -> bool {
43 matches!(self, ChannelType::Unstable)
44 }
45
46 pub(crate) 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 pub(crate) 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
71pub(crate) 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
83pub(crate) 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 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 if let Some((year, month)) = parse_version(ref_str) {
121 return ChannelType::BareVersion { year, month };
122 }
123
124 ChannelType::Unknown
125}
126
127fn parse_version(version: &str) -> Option<(u32, u32)> {
129 let parts: Vec<&str> = version.split('.').collect();
130 if parts.len() == 2 {
131 let year = parts[0].parse::<u32>().ok()?;
132 let month = parts[1].parse::<u32>().ok()?;
133 if (20..=99).contains(&year) && (month == 5 || month == 11) {
135 return Some((year, month));
136 }
137 }
138 None
139}
140
141pub(crate) fn find_latest_channel(
154 client: &ForgeClient,
155 current_ref: &str,
156 owner: &str,
157 repo: &str,
158 domain: Option<&str>,
159) -> Result<Option<String>, ApiError> {
160 let current_channel = parse_channel_ref(current_ref);
161
162 if current_channel.is_unstable() {
163 tracing::debug!("Skipping update for unstable channel: {}", current_ref);
164 return Ok(None);
165 }
166
167 let (prefix, current_version) = match (current_channel.prefix(), current_channel.version()) {
168 (Some(p), Some(v)) => (p, v),
169 _ => return Ok(None),
170 };
171
172 if let Some(latest) =
177 find_latest_channel_targeted(client, prefix, current_version, owner, repo, domain)?
178 {
179 if latest != current_ref {
180 return Ok(Some(latest));
181 } else {
182 tracing::debug!("{} is already on the latest channel", current_ref);
183 return Ok(None);
184 }
185 }
186
187 tracing::debug!("Targeted lookup failed, falling back to listing all branches");
188 let branches = client.list_branches(owner, repo, domain)?;
189 let latest = find_latest_matching_branch(&branches, prefix, current_version);
190
191 if let Some(ref latest_branch) = latest
192 && latest_branch == current_ref
193 {
194 tracing::debug!("{} is already on the latest channel", current_ref);
195 return Ok(None);
196 }
197
198 Ok(latest)
199}
200
201pub(crate) fn channel_probe_candidates(prefix: &str, current_version: (u32, u32)) -> Vec<String> {
207 let mut all = generate_candidate_channels(prefix, current_version);
208 all.push(format!(
209 "{}{}.{:02}",
210 prefix, current_version.0, current_version.1
211 ));
212 all
213}
214
215pub(crate) fn generate_candidate_channels(
218 prefix: &str,
219 current_version: (u32, u32),
220) -> Vec<String> {
221 let (current_year, current_month) = current_version;
222 let mut candidates = Vec::new();
223
224 let mut year = current_year;
229 let mut month = current_month;
230
231 for _ in 0..10 {
232 if month == 5 {
233 month = 11;
234 } else {
235 month = 5;
236 year += 1;
237 }
238
239 candidates.push(format!("{}{}.{:02}", prefix, year, month));
240 }
241
242 candidates.reverse();
245 candidates
246}
247
248fn find_latest_channel_targeted(
253 client: &ForgeClient,
254 prefix: &str,
255 current_version: (u32, u32),
256 owner: &str,
257 repo: &str,
258 domain: Option<&str>,
259) -> Result<Option<String>, ApiError> {
260 let candidates = generate_candidate_channels(prefix, current_version);
261
262 tracing::debug!(
263 "Checking candidate channels (newest first): {:?}",
264 candidates
265 );
266
267 for candidate in &candidates {
268 tracing::debug!("Checking if branch exists: {}", candidate);
269 if client.branch_exists(owner, repo, candidate, domain)? {
270 tracing::debug!("Found existing channel: {}", candidate);
271 return Ok(Some(candidate.clone()));
272 }
273 }
274
275 let current_branch = format!("{}{}.{:02}", prefix, current_version.0, current_version.1);
276 tracing::debug!("No newer channel, checking current: {}", current_branch);
277 if client.branch_exists(owner, repo, ¤t_branch, domain)? {
278 return Ok(Some(current_branch));
279 }
280
281 Ok(None)
282}
283
284fn find_latest_matching_branch(
286 branches: &Branches,
287 prefix: &str,
288 current_version: (u32, u32),
289) -> Option<String> {
290 let mut best: Option<(u32, u32, String)> = None;
291
292 for branch_name in &branches.names {
293 if let Some(version_str) = branch_name.strip_prefix(prefix) {
294 if version_str == "unstable" {
298 continue;
299 }
300
301 if let Some((year, month)) = parse_version(version_str)
302 && (year, month) >= current_version
303 {
304 match &best {
305 None => {
306 best = Some((year, month, branch_name.clone()));
307 }
308 Some((best_year, best_month, _)) => {
309 if (year, month) > (*best_year, *best_month) {
310 best = Some((year, month, branch_name.clone()));
311 }
312 }
313 }
314 }
315 }
316 }
317
318 best.map(|(_, _, name)| name)
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn test_detect_strategy() {
327 assert_eq!(
328 detect_strategy("nixos", "nixpkgs"),
329 UpdateStrategy::NixpkgsChannel
330 );
331 assert_eq!(
332 detect_strategy("NixOS", "nixpkgs"),
333 UpdateStrategy::NixpkgsChannel
334 );
335 assert_eq!(
336 detect_strategy("nix-community", "home-manager"),
337 UpdateStrategy::HomeManagerChannel
338 );
339 assert_eq!(
340 detect_strategy("LnL7", "nix-darwin"),
341 UpdateStrategy::NixDarwinChannel
342 );
343 assert_eq!(
344 detect_strategy("nix-community", "nix-darwin"),
345 UpdateStrategy::NixDarwinChannel
346 );
347 assert_eq!(
348 detect_strategy("some-user", "some-repo"),
349 UpdateStrategy::SemverTags
350 );
351 }
352
353 #[test]
354 fn test_parse_channel_ref_nixos() {
355 assert_eq!(
356 parse_channel_ref("nixos-24.11"),
357 ChannelType::NixosStable {
358 year: 24,
359 month: 11
360 }
361 );
362 assert_eq!(
363 parse_channel_ref("nixos-25.05"),
364 ChannelType::NixosStable { year: 25, month: 5 }
365 );
366 assert_eq!(parse_channel_ref("nixos-unstable"), ChannelType::Unstable);
367 }
368
369 #[test]
370 fn test_parse_channel_ref_nixpkgs() {
371 assert_eq!(
372 parse_channel_ref("nixpkgs-24.11"),
373 ChannelType::NixpkgsStable {
374 year: 24,
375 month: 11
376 }
377 );
378 assert_eq!(parse_channel_ref("nixpkgs-unstable"), ChannelType::Unstable);
379 }
380
381 #[test]
382 fn test_parse_channel_ref_home_manager() {
383 assert_eq!(
384 parse_channel_ref("release-24.11"),
385 ChannelType::HomeManagerRelease {
386 year: 24,
387 month: 11
388 }
389 );
390 assert_eq!(parse_channel_ref("master"), ChannelType::Unstable);
391 }
392
393 #[test]
394 fn test_parse_channel_ref_nix_darwin() {
395 assert_eq!(
396 parse_channel_ref("nix-darwin-24.11"),
397 ChannelType::NixDarwinStable {
398 year: 24,
399 month: 11
400 }
401 );
402 assert_eq!(parse_channel_ref("main"), ChannelType::Unstable);
403 }
404
405 #[test]
406 fn test_parse_channel_ref_bare_version() {
407 assert_eq!(
408 parse_channel_ref("24.11"),
409 ChannelType::BareVersion {
410 year: 24,
411 month: 11
412 }
413 );
414 assert_eq!(
415 parse_channel_ref("25.05"),
416 ChannelType::BareVersion { year: 25, month: 5 }
417 );
418 }
419
420 #[test]
421 fn test_parse_channel_ref_with_refs_heads_prefix() {
422 assert_eq!(
423 parse_channel_ref("refs/heads/nixos-24.11"),
424 ChannelType::NixosStable {
425 year: 24,
426 month: 11
427 }
428 );
429 }
430
431 #[test]
432 fn test_parse_channel_ref_unknown() {
433 assert_eq!(parse_channel_ref("v1.0.0"), ChannelType::Unknown);
434 assert_eq!(parse_channel_ref("nixos-invalid"), ChannelType::Unknown);
435 assert_eq!(parse_channel_ref("feature-branch"), ChannelType::Unknown);
436 }
437
438 #[test]
439 fn test_channel_type_is_unstable() {
440 assert!(ChannelType::Unstable.is_unstable());
441 assert!(parse_channel_ref("master").is_unstable());
442 assert!(parse_channel_ref("main").is_unstable());
443 assert!(parse_channel_ref("nixos-unstable").is_unstable());
444 assert!(
445 !ChannelType::NixosStable {
446 year: 24,
447 month: 11
448 }
449 .is_unstable()
450 );
451 }
452
453 #[test]
454 fn test_find_latest_matching_branch() {
455 let branches = Branches {
456 names: vec![
457 "nixos-23.11".to_string(),
458 "nixos-24.05".to_string(),
459 "nixos-24.11".to_string(),
460 "nixos-unstable".to_string(),
461 "master".to_string(),
462 ],
463 };
464
465 let result = find_latest_matching_branch(&branches, "nixos-", (24, 5));
467 assert_eq!(result, Some("nixos-24.11".to_string()));
468
469 let result = find_latest_matching_branch(&branches, "nixos-", (24, 11));
471 assert_eq!(result, Some("nixos-24.11".to_string()));
472
473 let result = find_latest_matching_branch(&branches, "nixos-", (25, 5));
475 assert_eq!(result, None);
476 }
477
478 #[test]
479 fn test_generate_candidate_channels() {
480 let candidates = generate_candidate_channels("nixos-", (24, 5));
483 assert_eq!(candidates.len(), 10);
484 assert_eq!(candidates[0], "nixos-29.05");
486 assert_eq!(candidates[9], "nixos-24.11");
488
489 let candidates = generate_candidate_channels("nixpkgs-", (24, 11));
492 assert_eq!(candidates[0], "nixpkgs-29.11"); assert_eq!(candidates[9], "nixpkgs-25.05"); }
495}