1use nix_uri::{FlakeRef, RefKind};
2use ropey::Rope;
3use std::cmp::Ordering;
4use std::collections::HashSet;
5use std::sync::Mutex;
6
7use super::api::{BatchLookup, ForgeClient};
8use super::archive::ArchiveUrl;
9use super::channel::{
10 ChannelType, UpdateStrategy, channel_probe_candidates, detect_strategy, find_latest_channel,
11 parse_channel_ref,
12};
13use super::channel_tarball::{ChannelTarballUrl, find_latest_published_channel};
14use super::version::{is_downgrade, parse_ref};
15use crate::edit::InputMap;
16use crate::input::Input;
17use crate::uri::is_git_url;
18
19const FETCH_CONCURRENCY: usize = 4;
22
23#[derive(Debug)]
29pub struct Updater {
30 text: Rope,
31 inputs: Vec<UpdateInput>,
32 offset: i32,
35 client: ForgeClient,
36}
37
38struct UpdatePlan {
44 previous_ref: String,
49 final_change: String,
54 updated_uri: String,
55}
56
57impl Updater {
58 fn print_update_status(id: &str, previous_version: &str, final_change: &str) -> bool {
59 let is_up_to_date = previous_version == final_change;
60 let initialized = previous_version.is_empty();
61
62 if is_up_to_date {
63 println!(
64 "{} is already on the latest version: {previous_version}.",
65 id
66 );
67 return false;
68 }
69
70 if initialized {
71 println!("Initialized {} version pin at {final_change}.", id);
72 } else {
73 println!("Updated {} from {previous_version} to {final_change}.", id);
74 }
75
76 true
77 }
78
79 pub fn new(text: Rope, map: InputMap) -> Self {
82 let client = ForgeClient::new();
83 let mut inputs = vec![];
84 for (_id, input) in map {
85 if !input.has_editable_url() {
86 continue;
87 }
88 let url_start = text.byte_to_char(input.range.start) + 1;
92 let url_end = text.byte_to_char(input.range.end) - 1;
93 inputs.push(UpdateInput {
94 input,
95 url_start,
96 url_end,
97 });
98 }
99 Self {
100 inputs,
101 text,
102 offset: 0,
103 client,
104 }
105 }
106
107 fn url_char_range(&self, input: &UpdateInput) -> (usize, usize) {
110 let start = (input.url_start as i32 + self.offset) as usize;
111 let end = (input.url_end as i32 + self.offset) as usize;
112 (start, end)
113 }
114 fn get_index(&self, id: &str) -> Option<usize> {
115 let bare = id
116 .strip_prefix('"')
117 .and_then(|s| s.strip_suffix('"'))
118 .unwrap_or(id);
119 self.inputs
120 .iter()
121 .position(|n| n.input.id().as_str() == bare)
122 }
123 pub fn pin_input_to_ref(&mut self, id: &str, rev: &str) -> Result<(), String> {
129 self.sort();
130 let idx = self.get_index(id).ok_or_else(|| id.to_string())?;
131 let input = self.inputs[idx].clone();
132 tracing::debug!("Input: {:?}", input);
133 self.change_input_to_rev(&input, rev);
134 Ok(())
135 }
136
137 pub fn unpin_input(&mut self, id: &str) -> Result<(), String> {
143 self.sort();
144 let idx = self.get_index(id).ok_or_else(|| id.to_string())?;
145 let input = self.inputs[idx].clone();
146 tracing::debug!("Input: {:?}", input);
147 self.remove_ref_and_rev(&input);
148 Ok(())
149 }
150
151 pub fn update_all_to_latest_semver(&mut self, init: bool) {
152 self.update_matching(|_| true, init);
153 }
154
155 pub fn update_inputs_to_latest_semver(&mut self, ids: &[&str], init: bool) {
161 if ids.is_empty() {
162 return;
163 }
164 let set: HashSet<&str> = ids.iter().copied().collect();
165 self.update_matching(|id| set.contains(id), init);
166 }
167
168 fn update_matching<F: Fn(&str) -> bool>(&mut self, keep: F, init: bool) {
181 self.sort();
182
183 let pending: Vec<(UpdateInput, String)> = self
188 .inputs
189 .iter()
190 .filter(|i| keep(i.input.id.as_str()))
191 .map(|i| {
192 let uri = self.get_input_text(i);
193 (i.clone(), uri)
194 })
195 .collect();
196
197 if pending.is_empty() {
198 return;
199 }
200
201 let github_lookups = build_github_batch_lookups(&pending);
207 if github_lookups.len() >= 2
213 && let Err(e) = self.client.batch_warm_github(&github_lookups)
214 {
215 tracing::debug!(
216 "GraphQL batch warm failed; falling back to REST per input: {}",
217 e
218 );
219 }
220
221 let results = parallel_fetch(&self.client, pending, init);
222
223 for (input, plan) in results {
224 let Some(plan) = plan else { continue };
225 if Self::print_update_status(
226 input.input.id.as_str(),
227 &plan.previous_ref,
228 &plan.final_change,
229 ) {
230 self.update_input(input, &plan.updated_uri);
231 }
232 }
233 }
234
235 pub fn get_changes(&self) -> String {
237 self.text.to_string()
238 }
239
240 fn get_input_text(&self, input: &UpdateInput) -> String {
241 let (start, end) = self.url_char_range(input);
242 self.text.slice(start..end).to_string()
243 }
244
245 pub(crate) fn change_input_to_rev(&mut self, input: &UpdateInput, rev: &str) {
247 let uri = self.get_input_text(input);
248 match uri.parse::<FlakeRef>() {
249 Ok(parsed) => {
250 let updated = parsed.pin_to_rev(rev.into()).into_uri();
251 self.update_input(input.clone(), &updated);
252 }
253 Err(e) => {
254 tracing::error!("Error while changing input: {}", e);
255 }
256 }
257 }
258 fn remove_ref_and_rev(&mut self, input: &UpdateInput) {
259 let uri = self.get_input_text(input);
260 match uri.parse::<FlakeRef>() {
261 Ok(mut parsed) => {
262 if parsed.ref_kind() == RefKind::None {
263 return;
264 }
265 parsed.set_ref(None);
266 parsed.set_rev(None);
267 self.update_input(input.clone(), &parsed.into_uri());
268 }
269 Err(e) => {
270 tracing::error!("Error while changing input: {}", e);
271 }
272 }
273 }
274 fn sort(&mut self) {
276 self.inputs.sort();
277 }
278 fn update_input(&mut self, input: UpdateInput, change: &str) {
279 let (start, end) = self.url_char_range(&input);
280 let previous_len = (end - start) as i32;
281 self.text.remove(start..end);
282 self.text.insert(start, change);
283 self.offset += change.chars().count() as i32 - previous_len;
284 }
285}
286
287#[derive(Debug, Clone)]
289pub(crate) struct UpdateInput {
290 input: Input,
291 url_start: usize,
294 url_end: usize,
296}
297
298impl Ord for UpdateInput {
299 fn cmp(&self, other: &Self) -> Ordering {
300 self.url_start.cmp(&other.url_start)
301 }
302}
303
304impl PartialEq for UpdateInput {
305 fn eq(&self, other: &Self) -> bool {
306 self.url_start == other.url_start
307 }
308}
309
310impl PartialOrd for UpdateInput {
311 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
312 Some(self.cmp(other))
313 }
314}
315
316impl Eq for UpdateInput {}
317
318fn parallel_fetch(
327 client: &ForgeClient,
328 pending: Vec<(UpdateInput, String)>,
329 init: bool,
330) -> Vec<(UpdateInput, Option<UpdatePlan>)> {
331 let n = pending.len();
332 if n == 0 {
333 return Vec::new();
334 }
335 let cap = std::cmp::min(n, FETCH_CONCURRENCY);
336
337 if cap <= 1 {
340 let mut results = Vec::with_capacity(n);
341 for (input, uri) in pending {
342 let plan = compute_change(client, &uri, init);
343 results.push((input, plan));
344 }
345 return results;
346 }
347
348 type WorkItem = (usize, UpdateInput, String);
352 type ResultSlot = Mutex<Option<(UpdateInput, Option<UpdatePlan>)>>;
353 let work: Mutex<Vec<WorkItem>> = Mutex::new(
354 pending
355 .into_iter()
356 .enumerate()
357 .map(|(i, (u, s))| (i, u, s))
358 .collect(),
359 );
360 let slots: Vec<ResultSlot> = (0..n).map(|_| Mutex::new(None)).collect();
361
362 std::thread::scope(|s| {
363 for _ in 0..cap {
364 let work = &work;
365 let slots = &slots;
366 s.spawn(move || {
367 loop {
368 let next = work.lock().expect("fetch work queue poisoned").pop();
369 let Some((idx, input, uri)) = next else { break };
370 let plan = compute_change(client, &uri, init);
371 *slots[idx].lock().expect("fetch result slot poisoned") = Some((input, plan));
372 }
373 });
374 }
375 });
376
377 slots
378 .into_iter()
379 .map(|m| {
380 m.into_inner()
381 .expect("fetch result slot poisoned")
382 .expect("scope returned with an unfilled fetch slot")
383 })
384 .collect()
385}
386
387fn build_github_batch_lookups(pending: &[(UpdateInput, String)]) -> Vec<BatchLookup> {
396 let mut lookups = Vec::new();
397 for (_, uri) in pending {
398 let Ok(parsed) = uri.parse::<FlakeRef>() else {
399 continue;
400 };
401 let canonical = match parsed.domain() {
405 None => "github.com",
406 Some(d) => d,
407 };
408 if canonical != "github.com" {
409 continue;
410 }
411 let (Some(owner), Some(repo)) = (parsed.owner(), parsed.repo()) else {
412 continue;
413 };
414 match detect_strategy(owner, repo) {
415 UpdateStrategy::SemverTags => {
416 lookups.push(BatchLookup::Tags {
417 owner: owner.to_string(),
418 repo: repo.to_string(),
419 });
420 }
421 UpdateStrategy::NixpkgsChannel
422 | UpdateStrategy::HomeManagerChannel
423 | UpdateStrategy::NixDarwinChannel => {
424 let current_ref = parsed.ref_or_rev().unwrap_or_default();
425 if current_ref.is_empty() {
426 continue;
427 }
428 let channel = parse_channel_ref(current_ref);
429 let (Some(prefix), Some(current_version)) = (channel.prefix(), channel.version())
430 else {
431 continue;
432 };
433 let candidates = channel_probe_candidates(prefix, current_version);
434 lookups.push(BatchLookup::ChannelCandidates {
435 owner: owner.to_string(),
436 repo: repo.to_string(),
437 prefix: prefix.to_string(),
438 candidates,
439 });
440 }
441 }
442 }
443 lookups
444}
445
446fn compute_change(client: &ForgeClient, uri: &str, init: bool) -> Option<UpdatePlan> {
455 if let Some(archive) = ArchiveUrl::parse(uri) {
458 return compute_archive_change(client, &archive, init);
459 }
460
461 if let Some(channel) = ChannelTarballUrl::parse(uri) {
462 return compute_channel_tarball_change(client, &channel);
463 }
464
465 let parsed = match uri.parse::<FlakeRef>() {
466 Ok(p) => p,
467 Err(e) => {
468 tracing::error!("Failed to parse URI: {}", e);
469 return None;
470 }
471 };
472
473 let owner = match parsed.owner() {
474 Some(o) => o.to_owned(),
475 None => {
476 tracing::debug!("Skipping input without owner");
477 return None;
478 }
479 };
480 let repo = match parsed.repo() {
481 Some(r) => r.to_owned(),
482 None => {
483 tracing::debug!("Skipping input without repo");
484 return None;
485 }
486 };
487
488 let strategy = detect_strategy(&owner, &repo);
489 tracing::debug!("Update strategy for {}/{}: {:?}", owner, repo, strategy);
490
491 match strategy {
492 UpdateStrategy::NixpkgsChannel
493 | UpdateStrategy::HomeManagerChannel
494 | UpdateStrategy::NixDarwinChannel => {
495 compute_channel_change(client, &parsed, &owner, &repo)
496 }
497 UpdateStrategy::SemverTags => {
498 compute_semver_change(client, uri, &parsed, &owner, &repo, init)
499 }
500 }
501}
502
503fn compute_archive_change(
511 client: &ForgeClient,
512 archive: &ArchiveUrl,
513 init: bool,
514) -> Option<UpdatePlan> {
515 let owner = archive.owner();
516 let repo = archive.repo();
517 let host = archive.host();
518 let current_ref = archive.ref_token();
519 let prefix = archive.ref_prefix_str();
520
521 if matches!(parse_channel_ref(current_ref), ChannelType::Unknown) {
522 let parsed_ref = parse_ref(current_ref, false);
523 if !init && semver::Version::parse(&parsed_ref.normalized_for_semver).is_err() {
524 tracing::debug!(
525 "Skipping archive input {}/{}: ref {} is neither a channel nor semver",
526 owner,
527 repo,
528 current_ref
529 );
530 return None;
531 }
532
533 let tags = match client.list_tags(owner, repo, Some(host)) {
534 Ok(t) => t,
535 Err(e) => {
536 tracing::error!(
537 "Failed to fetch tags for archive input {}/{} on {}: {}",
538 owner,
539 repo,
540 host,
541 e
542 );
543 return None;
544 }
545 };
546 let latest = match tags.get_latest_tag() {
547 Some(c) => c,
548 None => {
549 tracing::error!(
550 "Could not find latest version for archive input {}/{}",
551 owner,
552 repo
553 );
554 return None;
555 }
556 };
557
558 if !init && is_downgrade(current_ref, &latest) {
559 tracing::warn!(
560 "Refusing to downgrade archive input {}/{} from {} to {}",
561 owner,
562 repo,
563 current_ref,
564 latest
565 );
566 eprintln!(
567 "Warning: skipping {}/{}: latest tag {} is older than the current pin {}.",
568 owner, repo, latest, current_ref
569 );
570 return None;
571 }
572
573 Some(UpdatePlan {
574 previous_ref: format!("{prefix}{current_ref}"),
575 final_change: format!("{prefix}{latest}"),
576 updated_uri: archive.with_ref(&latest),
577 })
578 } else {
579 let latest = match find_latest_channel(client, current_ref, owner, repo, Some(host)) {
580 Ok(Some(latest)) => latest,
581 Ok(None) => return None,
582 Err(e) => {
583 tracing::error!(
584 "Failed to resolve latest channel for archive input {}/{}: {}",
585 owner,
586 repo,
587 e
588 );
589 return None;
590 }
591 };
592
593 Some(UpdatePlan {
594 previous_ref: format!("{prefix}{current_ref}"),
595 final_change: format!("{prefix}{latest}"),
596 updated_uri: archive.with_ref(&latest),
597 })
598 }
599}
600
601fn compute_channel_tarball_change(
604 client: &ForgeClient,
605 channel: &ChannelTarballUrl,
606) -> Option<UpdatePlan> {
607 let current = channel.channel();
608
609 let latest = match find_latest_published_channel(channel, |url| client.url_exists(url)) {
610 Ok(Some(latest)) => latest,
611 Ok(None) => return None,
612 Err(e) => {
613 tracing::error!(
614 "Failed to resolve latest published channel for {}: {}",
615 current,
616 e
617 );
618 return None;
619 }
620 };
621
622 Some(UpdatePlan {
623 previous_ref: current.to_string(),
624 final_change: latest.clone(),
625 updated_uri: channel.with_channel(&latest),
626 })
627}
628
629fn compute_channel_change(
636 client: &ForgeClient,
637 parsed: &FlakeRef,
638 owner: &str,
639 repo: &str,
640) -> Option<UpdatePlan> {
641 let domain = parsed.domain();
642 let current_ref = parsed.ref_or_rev().unwrap_or_default().to_owned();
643
644 if current_ref.is_empty() {
645 tracing::debug!("Skipping unpinned channel input: {}/{}", owner, repo);
646 return None;
647 }
648
649 let has_refs_heads_prefix = current_ref.starts_with("refs/heads/");
650
651 let latest = match find_latest_channel(client, ¤t_ref, owner, repo, domain) {
652 Ok(Some(latest)) => latest,
653 Ok(None) => return None,
654 Err(e) => {
655 tracing::error!(
656 "Failed to resolve latest channel for {}/{}: {}",
657 owner,
658 repo,
659 e
660 );
661 return None;
662 }
663 };
664
665 let final_ref = if has_refs_heads_prefix {
666 format!("refs/heads/{}", latest)
667 } else {
668 latest.clone()
669 };
670 let updated_uri = parsed.clone().with_ref(Some(final_ref.clone())).into_uri();
671
672 Some(UpdatePlan {
673 previous_ref: current_ref,
674 final_change: final_ref,
675 updated_uri,
676 })
677}
678
679fn compute_semver_change(
685 client: &ForgeClient,
686 uri: &str,
687 parsed: &FlakeRef,
688 owner: &str,
689 repo: &str,
690 init: bool,
691) -> Option<UpdatePlan> {
692 let is_git = is_git_url(uri);
693 let maybe_version = parsed.ref_or_rev().unwrap_or_default();
694 let parsed_ref = parse_ref(maybe_version, init);
695
696 if !init && let Err(e) = semver::Version::parse(&parsed_ref.normalized_for_semver) {
697 tracing::debug!("Skip non semver version: {}: {}", maybe_version, e);
698 return None;
699 }
700
701 let tags = if is_git {
702 let domain = parsed.domain()?.to_owned();
703 match client.list_tags(owner, repo, Some(&domain)) {
704 Ok(t) => t,
705 Err(_) => {
706 tracing::error!("Failed to fetch tags for {}/{} on {}", owner, repo, domain);
707 return None;
708 }
709 }
710 } else {
711 match client.list_tags(owner, repo, None) {
712 Ok(t) => t,
713 Err(_) => {
714 tracing::error!("Failed to fetch tags for {}/{}", owner, repo);
715 return None;
716 }
717 }
718 };
719
720 let change = match tags.get_latest_tag() {
721 Some(c) => c,
722 None => {
723 tracing::error!("Could not find latest version for {}/{}", owner, repo);
724 return None;
725 }
726 };
727
728 if !init && is_downgrade(maybe_version, &change) {
729 tracing::warn!(
730 "Refusing to downgrade {}/{} from {} to {}",
731 owner,
732 repo,
733 maybe_version,
734 change
735 );
736 eprintln!(
737 "Warning: skipping {}/{}: latest tag {} is older than the current pin {}.",
738 owner, repo, change, maybe_version
739 );
740 return None;
741 }
742
743 let final_change = if parsed_ref.has_refs_tags_prefix {
744 format!("refs/tags/{}", change)
745 } else {
746 change.clone()
747 };
748 let updated_uri = parsed
749 .clone()
750 .with_ref(Some(final_change.clone()))
751 .into_uri();
752
753 Some(UpdatePlan {
754 previous_ref: parsed_ref.previous_ref,
755 final_change,
756 updated_uri,
757 })
758}