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
215fn generate_candidate_channels(prefix: &str, current_version: (u32, u32)) -> Vec<String> {
218 let (current_year, current_month) = current_version;
219 let mut candidates = Vec::new();
220
221 let mut year = current_year;
226 let mut month = current_month;
227
228 for _ in 0..10 {
229 if month == 5 {
230 month = 11;
231 } else {
232 month = 5;
233 year += 1;
234 }
235
236 candidates.push(format!("{}{}.{:02}", prefix, year, month));
237 }
238
239 candidates.reverse();
242 candidates
243}
244
245fn find_latest_channel_targeted(
250 client: &ForgeClient,
251 prefix: &str,
252 current_version: (u32, u32),
253 owner: &str,
254 repo: &str,
255 domain: Option<&str>,
256) -> Result<Option<String>, ApiError> {
257 let candidates = generate_candidate_channels(prefix, current_version);
258
259 tracing::debug!(
260 "Checking candidate channels (newest first): {:?}",
261 candidates
262 );
263
264 for candidate in &candidates {
265 tracing::debug!("Checking if branch exists: {}", candidate);
266 if client.branch_exists(owner, repo, candidate, domain)? {
267 tracing::debug!("Found existing channel: {}", candidate);
268 return Ok(Some(candidate.clone()));
269 }
270 }
271
272 let current_branch = format!("{}{}.{:02}", prefix, current_version.0, current_version.1);
273 tracing::debug!("No newer channel, checking current: {}", current_branch);
274 if client.branch_exists(owner, repo, ¤t_branch, domain)? {
275 return Ok(Some(current_branch));
276 }
277
278 Ok(None)
279}
280
281fn find_latest_matching_branch(
283 branches: &Branches,
284 prefix: &str,
285 current_version: (u32, u32),
286) -> Option<String> {
287 let mut best: Option<(u32, u32, String)> = None;
288
289 for branch_name in &branches.names {
290 if let Some(version_str) = branch_name.strip_prefix(prefix) {
291 if version_str == "unstable" {
295 continue;
296 }
297
298 if let Some((year, month)) = parse_version(version_str)
299 && (year, month) >= current_version
300 {
301 match &best {
302 None => {
303 best = Some((year, month, branch_name.clone()));
304 }
305 Some((best_year, best_month, _)) => {
306 if (year, month) > (*best_year, *best_month) {
307 best = Some((year, month, branch_name.clone()));
308 }
309 }
310 }
311 }
312 }
313 }
314
315 best.map(|(_, _, name)| name)
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn test_detect_strategy() {
324 assert_eq!(
325 detect_strategy("nixos", "nixpkgs"),
326 UpdateStrategy::NixpkgsChannel
327 );
328 assert_eq!(
329 detect_strategy("NixOS", "nixpkgs"),
330 UpdateStrategy::NixpkgsChannel
331 );
332 assert_eq!(
333 detect_strategy("nix-community", "home-manager"),
334 UpdateStrategy::HomeManagerChannel
335 );
336 assert_eq!(
337 detect_strategy("LnL7", "nix-darwin"),
338 UpdateStrategy::NixDarwinChannel
339 );
340 assert_eq!(
341 detect_strategy("nix-community", "nix-darwin"),
342 UpdateStrategy::NixDarwinChannel
343 );
344 assert_eq!(
345 detect_strategy("some-user", "some-repo"),
346 UpdateStrategy::SemverTags
347 );
348 }
349
350 #[test]
351 fn test_parse_channel_ref_nixos() {
352 assert_eq!(
353 parse_channel_ref("nixos-24.11"),
354 ChannelType::NixosStable {
355 year: 24,
356 month: 11
357 }
358 );
359 assert_eq!(
360 parse_channel_ref("nixos-25.05"),
361 ChannelType::NixosStable { year: 25, month: 5 }
362 );
363 assert_eq!(parse_channel_ref("nixos-unstable"), ChannelType::Unstable);
364 }
365
366 #[test]
367 fn test_parse_channel_ref_nixpkgs() {
368 assert_eq!(
369 parse_channel_ref("nixpkgs-24.11"),
370 ChannelType::NixpkgsStable {
371 year: 24,
372 month: 11
373 }
374 );
375 assert_eq!(parse_channel_ref("nixpkgs-unstable"), ChannelType::Unstable);
376 }
377
378 #[test]
379 fn test_parse_channel_ref_home_manager() {
380 assert_eq!(
381 parse_channel_ref("release-24.11"),
382 ChannelType::HomeManagerRelease {
383 year: 24,
384 month: 11
385 }
386 );
387 assert_eq!(parse_channel_ref("master"), ChannelType::Unstable);
388 }
389
390 #[test]
391 fn test_parse_channel_ref_nix_darwin() {
392 assert_eq!(
393 parse_channel_ref("nix-darwin-24.11"),
394 ChannelType::NixDarwinStable {
395 year: 24,
396 month: 11
397 }
398 );
399 assert_eq!(parse_channel_ref("main"), ChannelType::Unstable);
400 }
401
402 #[test]
403 fn test_parse_channel_ref_bare_version() {
404 assert_eq!(
405 parse_channel_ref("24.11"),
406 ChannelType::BareVersion {
407 year: 24,
408 month: 11
409 }
410 );
411 assert_eq!(
412 parse_channel_ref("25.05"),
413 ChannelType::BareVersion { year: 25, month: 5 }
414 );
415 }
416
417 #[test]
418 fn test_parse_channel_ref_with_refs_heads_prefix() {
419 assert_eq!(
420 parse_channel_ref("refs/heads/nixos-24.11"),
421 ChannelType::NixosStable {
422 year: 24,
423 month: 11
424 }
425 );
426 }
427
428 #[test]
429 fn test_parse_channel_ref_unknown() {
430 assert_eq!(parse_channel_ref("v1.0.0"), ChannelType::Unknown);
431 assert_eq!(parse_channel_ref("nixos-invalid"), ChannelType::Unknown);
432 assert_eq!(parse_channel_ref("feature-branch"), ChannelType::Unknown);
433 }
434
435 #[test]
436 fn test_channel_type_is_unstable() {
437 assert!(ChannelType::Unstable.is_unstable());
438 assert!(parse_channel_ref("master").is_unstable());
439 assert!(parse_channel_ref("main").is_unstable());
440 assert!(parse_channel_ref("nixos-unstable").is_unstable());
441 assert!(
442 !ChannelType::NixosStable {
443 year: 24,
444 month: 11
445 }
446 .is_unstable()
447 );
448 }
449
450 #[test]
451 fn test_find_latest_matching_branch() {
452 let branches = Branches {
453 names: vec![
454 "nixos-23.11".to_string(),
455 "nixos-24.05".to_string(),
456 "nixos-24.11".to_string(),
457 "nixos-unstable".to_string(),
458 "master".to_string(),
459 ],
460 };
461
462 let result = find_latest_matching_branch(&branches, "nixos-", (24, 5));
464 assert_eq!(result, Some("nixos-24.11".to_string()));
465
466 let result = find_latest_matching_branch(&branches, "nixos-", (24, 11));
468 assert_eq!(result, Some("nixos-24.11".to_string()));
469
470 let result = find_latest_matching_branch(&branches, "nixos-", (25, 5));
472 assert_eq!(result, None);
473 }
474
475 #[test]
476 fn test_generate_candidate_channels() {
477 let candidates = generate_candidate_channels("nixos-", (24, 5));
480 assert_eq!(candidates.len(), 10);
481 assert_eq!(candidates[0], "nixos-29.05");
483 assert_eq!(candidates[9], "nixos-24.11");
485
486 let candidates = generate_candidate_channels("nixpkgs-", (24, 11));
489 assert_eq!(candidates[0], "nixpkgs-29.11"); assert_eq!(candidates[9], "nixpkgs-25.05"); }
492}