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