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            inputs.push(UpdateInput { input });
180        }
181        Self {
182            inputs,
183            text,
184            offset: 0,
185        }
186    }
187    fn get_index(&self, id: &str) -> usize {
188        self.inputs.iter().position(|n| n.input.id == id).unwrap()
189    }
190    /// Pin an input based on it's id to a specific rev.
191    pub fn pin_input_to_ref(&mut self, id: &str, rev: &str) {
192        self.sort();
193        let inputs = self.inputs.clone();
194        if let Some(input) = inputs.get(self.get_index(id)) {
195            tracing::debug!("Input: {:?}", input);
196            self.change_input_to_rev(input, rev);
197        }
198    }
199    /// Remove any ?ref= or ?rev= parameters from a specific input.
200    pub fn unpin_input(&mut self, id: &str) {
201        self.sort();
202        let inputs = self.inputs.clone();
203        if let Some(input) = inputs.get(self.get_index(id)) {
204            tracing::debug!("Input: {:?}", input);
205            self.remove_ref_and_rev(input);
206        }
207    }
208    /// Update all inputs to a specific semver release,
209    /// if a specific input is given, just update the single input.
210    pub fn update_all_inputs_to_latest_semver(&mut self, id: Option<String>, init: bool) {
211        self.sort();
212        let inputs = self.inputs.clone();
213        for input in inputs.iter() {
214            if let Some(ref input_id) = id {
215                if input.input.id == *input_id {
216                    self.query_and_update_all_inputs(input, init);
217                }
218            } else {
219                self.query_and_update_all_inputs(input, init);
220            }
221        }
222    }
223    pub fn get_changes(&self) -> String {
224        self.text.to_string()
225    }
226
227    fn get_input_text(&self, input: &UpdateInput) -> String {
228        self.text
229            .slice(
230                ((input.input.range.start as i32) + 1 + self.offset) as usize
231                    ..((input.input.range.end as i32) + self.offset - 1) as usize,
232            )
233            .to_string()
234    }
235
236    /// Change a specific input to a specific rev.
237    pub fn change_input_to_rev(&mut self, input: &UpdateInput, rev: &str) {
238        let uri = self.get_input_text(input);
239        match uri.parse::<FlakeRef>() {
240            Ok(mut parsed) => {
241                // set_rev() preserves storage location (path vs query param)
242                let _ = parsed.set_rev(Some(rev.into()));
243                self.update_input(input.clone(), &parsed.to_string());
244            }
245            Err(e) => {
246                tracing::error!("Error while changing input: {}", e);
247            }
248        }
249    }
250    fn remove_ref_and_rev(&mut self, input: &UpdateInput) {
251        let uri = self.get_input_text(input);
252        match uri.parse::<FlakeRef>() {
253            Ok(mut parsed) => {
254                if parsed.ref_source_location() == RefLocation::None {
255                    return;
256                }
257                // set_ref/set_rev handle both path-based and query param storage
258                let _ = parsed.set_ref(None);
259                let _ = parsed.set_rev(None);
260                self.update_input(input.clone(), &parsed.to_string());
261            }
262            Err(e) => {
263                tracing::error!("Error while changing input: {}", e);
264            }
265        }
266    }
267    /// Query a forge api for the latest release and update, if necessary.
268    pub fn query_and_update_all_inputs(&mut self, input: &UpdateInput, init: bool) {
269        let uri = self.get_input_text(input);
270
271        let parsed = match uri.parse::<FlakeRef>() {
272            Ok(parsed) => parsed,
273            Err(e) => {
274                tracing::error!("Failed to parse URI: {}", e);
275                return;
276            }
277        };
278
279        let owner = match parsed.r#type.get_owner() {
280            Some(o) => o,
281            None => {
282                tracing::debug!("Skipping input without owner");
283                return;
284            }
285        };
286
287        let repo = match parsed.r#type.get_repo() {
288            Some(r) => r,
289            None => {
290                tracing::debug!("Skipping input without repo");
291                return;
292            }
293        };
294
295        let strategy = detect_strategy(&owner, &repo);
296        tracing::debug!("Update strategy for {}/{}: {:?}", owner, repo, strategy);
297
298        match strategy {
299            UpdateStrategy::NixpkgsChannel
300            | UpdateStrategy::HomeManagerChannel
301            | UpdateStrategy::NixDarwinChannel => {
302                self.update_channel_input(input, &parsed);
303            }
304            UpdateStrategy::SemverTags => {
305                self.update_semver_input(input, init);
306            }
307        }
308    }
309
310    /// Update an input using channel-based versioning (nixpkgs, home-manager, nix-darwin).
311    fn update_channel_input(&mut self, input: &UpdateInput, parsed: &FlakeRef) {
312        let owner = parsed.r#type.get_owner().unwrap();
313        let repo = parsed.r#type.get_repo().unwrap();
314        let domain = parsed.r#type.get_domain();
315
316        let current_ref = parsed.get_ref_or_rev().unwrap_or_default();
317
318        if current_ref.is_empty() {
319            tracing::debug!("Skipping unpinned channel input: {}", input.input.id);
320            return;
321        }
322
323        let has_refs_heads_prefix = current_ref.starts_with("refs/heads/");
324
325        let latest = match find_latest_channel(&current_ref, &owner, &repo, domain.as_deref()) {
326            Some(latest) => latest,
327            // Either already on latest, unstable, or not a recognized channel
328            None => return,
329        };
330
331        let final_ref = if has_refs_heads_prefix {
332            format!("refs/heads/{}", latest)
333        } else {
334            latest.clone()
335        };
336
337        let mut parsed = parsed.clone();
338        let _ = parsed.set_ref(Some(final_ref.clone()));
339        let updated_uri = parsed.to_string();
340
341        if Self::print_update_status(&input.input.id, &current_ref, &final_ref) {
342            self.update_input(input.clone(), &updated_uri);
343        }
344    }
345
346    /// Update an input using semver tag-based versioning (standard behavior).
347    fn update_semver_input(&mut self, input: &UpdateInput, init: bool) {
348        let target = match self.parse_update_target(input, init) {
349            Some(target) => target,
350            None => return,
351        };
352
353        let tags = match self.fetch_tags(&target) {
354            Some(tags) => tags,
355            None => return,
356        };
357
358        self.apply_update(input, &target, tags, init);
359    }
360
361    // Sort the entries, so that we can adjust multiple values together
362    fn sort(&mut self) {
363        self.inputs.sort();
364    }
365    fn update_input(&mut self, input: UpdateInput, change: &str) {
366        self.text.remove(
367            (input.input.range.start as i32 + 1 + self.offset) as usize
368                ..(input.input.range.end as i32 - 1 + self.offset) as usize,
369        );
370        self.text.insert(
371            (input.input.range.start as i32 + 1 + self.offset) as usize,
372            change,
373        );
374        self.update_offset(input.clone(), change);
375    }
376    fn update_offset(&mut self, input: UpdateInput, change: &str) {
377        let previous_len = input.input.range.end as i32 - input.input.range.start as i32 - 2;
378        let len = change.len() as i32;
379        let offset = len - previous_len;
380        self.offset += offset;
381    }
382}
383
384// Wrapper around  individual inputs
385#[derive(Debug, Clone)]
386pub struct UpdateInput {
387    input: Input,
388}
389
390impl Ord for UpdateInput {
391    fn cmp(&self, other: &Self) -> Ordering {
392        (self.input.range.start).cmp(&(other.input.range.start))
393    }
394}
395
396impl PartialEq for UpdateInput {
397    fn eq(&self, other: &Self) -> bool {
398        self.input.range.start == other.input.range.start
399    }
400}
401
402impl PartialOrd for UpdateInput {
403    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
404        Some(self.cmp(other))
405    }
406}
407
408impl Eq for UpdateInput {}