Skip to main content

flake_edit/
update.rs

1use nix_uri::{FlakeRef, RefLocation};
2use ropey::Rope;
3use std::cmp::Ordering;
4
5use crate::channel::{UpdateStrategy, detect_strategy, find_latest_channel};
6use crate::edit::InputMap;
7use crate::input::Input;
8use crate::uri::is_git_url;
9use crate::version::parse_ref;
10
11#[derive(Default, Debug)]
12pub struct Updater {
13    text: Rope,
14    inputs: Vec<UpdateInput>,
15    // Keeps track of offset for changing multiple inputs on a single pass.
16    offset: i32,
17}
18
19enum UpdateTarget {
20    GitUrl {
21        parsed: Box<FlakeRef>,
22        owner: String,
23        repo: String,
24        domain: String,
25        parsed_ref: crate::version::ParsedRef,
26    },
27    ForgeRef {
28        parsed: Box<FlakeRef>,
29        owner: String,
30        repo: String,
31        parsed_ref: crate::version::ParsedRef,
32    },
33}
34
35impl Updater {
36    fn print_update_status(id: &str, previous_version: &str, final_change: &str) -> bool {
37        let is_up_to_date = previous_version == final_change;
38        let initialized = previous_version.is_empty();
39
40        if is_up_to_date {
41            println!(
42                "{} is already on the latest version: {previous_version}.",
43                id
44            );
45            return false;
46        }
47
48        if initialized {
49            println!("Initialized {} version pin at {final_change}.", id);
50        } else {
51            println!("Updated {} from {previous_version} to {final_change}.", id);
52        }
53
54        true
55    }
56    fn parse_update_target(&self, input: &UpdateInput, init: bool) -> Option<UpdateTarget> {
57        let uri = self.get_input_text(input);
58        let is_git_url = is_git_url(&uri);
59
60        let parsed = match uri.parse::<FlakeRef>() {
61            Ok(parsed) => parsed,
62            Err(e) => {
63                tracing::error!("Failed to parse URI: {}", e);
64                return None;
65            }
66        };
67
68        let maybe_version = parsed.get_ref_or_rev().unwrap_or_default();
69        let parsed_ref = parse_ref(&maybe_version, init);
70
71        if !init && let Err(e) = semver::Version::parse(&parsed_ref.normalized_for_semver) {
72            tracing::debug!("Skip non semver version: {}: {}", maybe_version, e);
73            return None;
74        }
75
76        let owner = match parsed.r#type.get_owner() {
77            Some(o) => o,
78            None => {
79                tracing::debug!("Skipping input without owner");
80                return None;
81            }
82        };
83
84        let repo = match parsed.r#type.get_repo() {
85            Some(r) => r,
86            None => {
87                tracing::debug!("Skipping input without repo");
88                return None;
89            }
90        };
91
92        if is_git_url {
93            let domain = parsed.r#type.get_domain()?;
94            return Some(UpdateTarget::GitUrl {
95                parsed: Box::new(parsed),
96                owner,
97                repo,
98                domain,
99                parsed_ref,
100            });
101        }
102
103        Some(UpdateTarget::ForgeRef {
104            parsed: Box::new(parsed),
105            owner,
106            repo,
107            parsed_ref,
108        })
109    }
110
111    fn fetch_tags(&self, target: &UpdateTarget) -> Option<crate::api::Tags> {
112        match target {
113            UpdateTarget::GitUrl {
114                owner,
115                repo,
116                domain,
117                ..
118            } => match crate::api::get_tags(repo, owner, Some(domain)) {
119                Ok(tags) => Some(tags),
120                Err(_) => {
121                    tracing::error!("Failed to fetch tags for {}/{} on {}", owner, repo, domain);
122                    None
123                }
124            },
125            UpdateTarget::ForgeRef { owner, repo, .. } => {
126                match crate::api::get_tags(repo, owner, None) {
127                    Ok(tags) => Some(tags),
128                    Err(_) => {
129                        tracing::error!("Failed to fetch tags for {}/{}", owner, repo);
130                        None
131                    }
132                }
133            }
134        }
135    }
136
137    fn apply_update(
138        &mut self,
139        input: &UpdateInput,
140        target: &UpdateTarget,
141        mut tags: crate::api::Tags,
142        _init: bool,
143    ) {
144        tags.sort();
145        if let Some(change) = tags.get_latest_tag() {
146            let (parsed, parsed_ref) = match target {
147                UpdateTarget::GitUrl {
148                    parsed, parsed_ref, ..
149                } => (parsed, parsed_ref),
150                UpdateTarget::ForgeRef {
151                    parsed, parsed_ref, ..
152                } => (parsed, parsed_ref),
153            };
154
155            let final_change = if parsed_ref.has_refs_tags_prefix {
156                format!("refs/tags/{}", change)
157            } else {
158                change.clone()
159            };
160
161            // set_ref() preserves storage location (path vs query param)
162            let mut parsed = parsed.clone();
163            let _ = parsed.set_ref(Some(final_change.clone()));
164            let updated_uri = parsed.to_string();
165
166            if !Self::print_update_status(&input.input.id, &parsed_ref.previous_ref, &final_change)
167            {
168                return;
169            }
170
171            self.update_input(input.clone(), &updated_uri);
172        } else {
173            tracing::error!("Could not find latest version for Input: {:?}", input);
174        }
175    }
176    pub fn new(text: Rope, map: InputMap) -> Self {
177        let mut inputs = vec![];
178        for (_id, input) in map {
179            // Skip inputs without a URL (e.g. expanded type/owner/repo/ref format
180            // or follows-only stubs) — there is no quoted URL value to modify.
181            if input.url.is_empty() || input.range.start == 0 && input.range.end == 0 {
182                continue;
183            }
184            inputs.push(UpdateInput { input });
185        }
186        Self {
187            inputs,
188            text,
189            offset: 0,
190        }
191    }
192    fn get_index(&self, id: &str) -> Option<usize> {
193        let bare = id
194            .strip_prefix('"')
195            .and_then(|s| s.strip_suffix('"'))
196            .unwrap_or(id);
197        self.inputs.iter().position(|n| n.input.bare_id() == bare)
198    }
199    /// Pin an input based on it's id to a specific rev.
200    pub fn pin_input_to_ref(&mut self, id: &str, rev: &str) -> Result<(), String> {
201        self.sort();
202        let idx = self.get_index(id).ok_or_else(|| id.to_string())?;
203        let input = self.inputs[idx].clone();
204        tracing::debug!("Input: {:?}", input);
205        self.change_input_to_rev(&input, rev);
206        Ok(())
207    }
208    /// Remove any ?ref= or ?rev= parameters from a specific input.
209    pub fn unpin_input(&mut self, id: &str) -> Result<(), String> {
210        self.sort();
211        let idx = self.get_index(id).ok_or_else(|| id.to_string())?;
212        let input = self.inputs[idx].clone();
213        tracing::debug!("Input: {:?}", input);
214        self.remove_ref_and_rev(&input);
215        Ok(())
216    }
217    /// Update all inputs to a specific semver release,
218    /// if a specific input is given, just update the single input.
219    pub fn update_all_inputs_to_latest_semver(&mut self, id: Option<String>, init: bool) {
220        self.sort();
221        let inputs = self.inputs.clone();
222        for input in inputs.iter() {
223            if let Some(ref input_id) = id {
224                if input.input.id == *input_id {
225                    self.query_and_update_all_inputs(input, init);
226                }
227            } else {
228                self.query_and_update_all_inputs(input, init);
229            }
230        }
231    }
232    pub fn get_changes(&self) -> String {
233        self.text.to_string()
234    }
235
236    fn get_input_text(&self, input: &UpdateInput) -> String {
237        self.text
238            .slice(
239                ((input.input.range.start as i32) + 1 + self.offset) as usize
240                    ..((input.input.range.end as i32) + self.offset - 1) as usize,
241            )
242            .to_string()
243    }
244
245    /// Change a specific input to a specific rev.
246    pub 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(mut parsed) => {
250                // set_rev() preserves storage location (path vs query param)
251                let _ = parsed.set_rev(Some(rev.into()));
252                self.update_input(input.clone(), &parsed.to_string());
253            }
254            Err(e) => {
255                tracing::error!("Error while changing input: {}", e);
256            }
257        }
258    }
259    fn remove_ref_and_rev(&mut self, input: &UpdateInput) {
260        let uri = self.get_input_text(input);
261        match uri.parse::<FlakeRef>() {
262            Ok(mut parsed) => {
263                if parsed.ref_source_location() == RefLocation::None {
264                    return;
265                }
266                // set_ref/set_rev handle both path-based and query param storage
267                let _ = parsed.set_ref(None);
268                let _ = parsed.set_rev(None);
269                self.update_input(input.clone(), &parsed.to_string());
270            }
271            Err(e) => {
272                tracing::error!("Error while changing input: {}", e);
273            }
274        }
275    }
276    /// Query a forge api for the latest release and update, if necessary.
277    pub fn query_and_update_all_inputs(&mut self, input: &UpdateInput, init: bool) {
278        let uri = self.get_input_text(input);
279
280        let parsed = match uri.parse::<FlakeRef>() {
281            Ok(parsed) => parsed,
282            Err(e) => {
283                tracing::error!("Failed to parse URI: {}", e);
284                return;
285            }
286        };
287
288        let owner = match parsed.r#type.get_owner() {
289            Some(o) => o,
290            None => {
291                tracing::debug!("Skipping input without owner");
292                return;
293            }
294        };
295
296        let repo = match parsed.r#type.get_repo() {
297            Some(r) => r,
298            None => {
299                tracing::debug!("Skipping input without repo");
300                return;
301            }
302        };
303
304        let strategy = detect_strategy(&owner, &repo);
305        tracing::debug!("Update strategy for {}/{}: {:?}", owner, repo, strategy);
306
307        match strategy {
308            UpdateStrategy::NixpkgsChannel
309            | UpdateStrategy::HomeManagerChannel
310            | UpdateStrategy::NixDarwinChannel => {
311                self.update_channel_input(input, &parsed);
312            }
313            UpdateStrategy::SemverTags => {
314                self.update_semver_input(input, init);
315            }
316        }
317    }
318
319    /// Update an input using channel-based versioning (nixpkgs, home-manager, nix-darwin).
320    fn update_channel_input(&mut self, input: &UpdateInput, parsed: &FlakeRef) {
321        let owner = parsed.r#type.get_owner().unwrap();
322        let repo = parsed.r#type.get_repo().unwrap();
323        let domain = parsed.r#type.get_domain();
324
325        let current_ref = parsed.get_ref_or_rev().unwrap_or_default();
326
327        if current_ref.is_empty() {
328            tracing::debug!("Skipping unpinned channel input: {}", input.input.id);
329            return;
330        }
331
332        let has_refs_heads_prefix = current_ref.starts_with("refs/heads/");
333
334        let latest = match find_latest_channel(&current_ref, &owner, &repo, domain.as_deref()) {
335            Some(latest) => latest,
336            // Either already on latest, unstable, or not a recognized channel
337            None => return,
338        };
339
340        let final_ref = if has_refs_heads_prefix {
341            format!("refs/heads/{}", latest)
342        } else {
343            latest.clone()
344        };
345
346        let mut parsed = parsed.clone();
347        let _ = parsed.set_ref(Some(final_ref.clone()));
348        let updated_uri = parsed.to_string();
349
350        if Self::print_update_status(&input.input.id, &current_ref, &final_ref) {
351            self.update_input(input.clone(), &updated_uri);
352        }
353    }
354
355    /// Update an input using semver tag-based versioning (standard behavior).
356    fn update_semver_input(&mut self, input: &UpdateInput, init: bool) {
357        let target = match self.parse_update_target(input, init) {
358            Some(target) => target,
359            None => return,
360        };
361
362        let tags = match self.fetch_tags(&target) {
363            Some(tags) => tags,
364            None => return,
365        };
366
367        self.apply_update(input, &target, tags, init);
368    }
369
370    // Sort the entries, so that we can adjust multiple values together
371    fn sort(&mut self) {
372        self.inputs.sort();
373    }
374    fn update_input(&mut self, input: UpdateInput, change: &str) {
375        self.text.remove(
376            (input.input.range.start as i32 + 1 + self.offset) as usize
377                ..(input.input.range.end as i32 - 1 + self.offset) as usize,
378        );
379        self.text.insert(
380            (input.input.range.start as i32 + 1 + self.offset) as usize,
381            change,
382        );
383        self.update_offset(input.clone(), change);
384    }
385    fn update_offset(&mut self, input: UpdateInput, change: &str) {
386        let previous_len = input.input.range.end as i32 - input.input.range.start as i32 - 2;
387        let len = change.len() as i32;
388        let offset = len - previous_len;
389        self.offset += offset;
390    }
391}
392
393// Wrapper around  individual inputs
394#[derive(Debug, Clone)]
395pub struct UpdateInput {
396    input: Input,
397}
398
399impl Ord for UpdateInput {
400    fn cmp(&self, other: &Self) -> Ordering {
401        (self.input.range.start).cmp(&(other.input.range.start))
402    }
403}
404
405impl PartialEq for UpdateInput {
406    fn eq(&self, other: &Self) -> bool {
407        self.input.range.start == other.input.range.start
408    }
409}
410
411impl PartialOrd for UpdateInput {
412    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
413        Some(self.cmp(other))
414    }
415}
416
417impl Eq for UpdateInput {}