Skip to main content

flake_edit/
change.rs

1use crate::follows::{AttrPath, AttrPathParseError, Segment};
2use crate::walk::Context;
3
4#[derive(Debug, Default, Clone, serde::Serialize)]
5pub enum Change {
6    #[default]
7    None,
8    Add {
9        id: Option<ChangeId>,
10        uri: Option<String>,
11        // Add an input as a flake.
12        flake: bool,
13    },
14    Remove {
15        ids: Vec<ChangeId>,
16    },
17    Change {
18        id: Option<ChangeId>,
19        uri: Option<String>,
20    },
21    /// Redirect a nested input to follow another input.
22    ///
23    /// Applying `Follows { input: "rust-overlay.nixpkgs", target: "nixpkgs" }`
24    /// writes `rust-overlay.inputs.nixpkgs.follows = "nixpkgs";`.
25    Follows {
26        /// Path to the nested input being redirected (e.g.
27        /// `rust-overlay.nixpkgs`).
28        input: ChangeId,
29        /// The input to follow.
30        target: AttrPath,
31    },
32}
33
34/// Identifier for an input or nested-input target of a [`Change`].
35///
36/// Wraps an [`AttrPath`]: a non-empty sequence of unquoted segments matching
37/// flake-side attribute path grammar.
38#[derive(Debug, Clone, PartialEq, serde::Serialize)]
39pub struct ChangeId(AttrPath);
40
41impl ChangeId {
42    pub fn new(path: AttrPath) -> Self {
43        ChangeId(path)
44    }
45
46    /// Parse a dotted attribute path. Quoted segments (e.g. `"hls-1.10"`)
47    /// retain dots that would otherwise act as separators.
48    pub fn parse(s: &str) -> Result<Self, AttrPathParseError> {
49        Ok(ChangeId(AttrPath::parse(s)?))
50    }
51
52    pub fn path(&self) -> &AttrPath {
53        &self.0
54    }
55
56    /// Top-level input segment: the segment before the first dot, or the whole
57    /// path if it has only one segment.
58    pub fn input(&self) -> &Segment {
59        self.0.first()
60    }
61
62    /// Second segment, if the path has more than one segment.
63    pub fn follows(&self) -> Option<&Segment> {
64        self.0.child()
65    }
66
67    fn matches(&self, input: &Segment, follows: Option<&Segment>) -> bool {
68        if self.input() != input {
69            return false;
70        }
71        match (self.follows(), follows) {
72            (Some(self_follows), Some(f)) => self_follows == f,
73            (Some(_), None) => false,
74            (None, _) => true,
75        }
76    }
77
78    /// Match against an explicit `(input, follows)` pair.
79    pub fn matches_with_follows(&self, input: &Segment, follows: Option<&Segment>) -> bool {
80        self.matches(input, follows)
81    }
82
83    /// Match against the surrounding walker [`Context`], which carries the
84    /// enclosing top-level input.
85    pub fn matches_with_ctx(&self, follows: &Segment, ctx: Option<Context>) -> bool {
86        let ctx_input = ctx.and_then(|c| c.first().cloned());
87        match ctx_input {
88            Some(input) => self.matches(&input, Some(follows)),
89            None => self.input() == follows,
90        }
91    }
92}
93
94impl std::fmt::Display for ChangeId {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(f, "{}", self.0)
97    }
98}
99
100impl TryFrom<String> for ChangeId {
101    type Error = AttrPathParseError;
102
103    fn try_from(value: String) -> Result<Self, Self::Error> {
104        ChangeId::parse(&value)
105    }
106}
107
108impl TryFrom<&str> for ChangeId {
109    type Error = AttrPathParseError;
110
111    fn try_from(value: &str) -> Result<Self, Self::Error> {
112        ChangeId::parse(value)
113    }
114}
115
116impl From<AttrPath> for ChangeId {
117    fn from(value: AttrPath) -> Self {
118        ChangeId(value)
119    }
120}
121
122impl From<Segment> for ChangeId {
123    fn from(value: Segment) -> Self {
124        ChangeId(AttrPath::new(value))
125    }
126}
127
128impl Change {
129    pub fn id(&self) -> Option<ChangeId> {
130        match self {
131            Change::None => None,
132            Change::Add { id, .. } => id.clone(),
133            Change::Remove { ids } => ids.first().cloned(),
134            Change::Change { id, .. } => id.clone(),
135            Change::Follows { input, .. } => Some(input.clone()),
136        }
137    }
138
139    pub fn ids(&self) -> Vec<ChangeId> {
140        match self {
141            Change::Remove { ids } => ids.clone(),
142            Change::Follows { input, .. } => vec![input.clone()],
143            _ => self.id().into_iter().collect(),
144        }
145    }
146    pub fn is_remove(&self) -> bool {
147        matches!(self, Change::Remove { .. })
148    }
149    pub fn is_follows(&self) -> bool {
150        matches!(self, Change::Follows { .. })
151    }
152    pub fn uri(&self) -> Option<&String> {
153        match self {
154            Change::Change { uri, .. } | Change::Add { uri, .. } => uri.as_ref(),
155            _ => None,
156        }
157    }
158    pub fn follows_target(&self) -> Option<&AttrPath> {
159        match self {
160            Change::Follows { target, .. } => Some(target),
161            _ => None,
162        }
163    }
164
165    pub fn success_messages(&self) -> Vec<String> {
166        match self {
167            Change::Add { id, uri, .. } => {
168                let id = id.as_ref().map(ChangeId::to_string);
169                vec![format!(
170                    "Added input: {} = {}",
171                    id.as_deref().unwrap_or("?"),
172                    uri.as_deref().unwrap_or("?")
173                )]
174            }
175            Change::Remove { ids } => ids
176                .iter()
177                .map(|id| format!("Removed input: {}", id))
178                .collect(),
179            Change::Change { id, uri, .. } => {
180                let id = id.as_ref().map(ChangeId::to_string);
181                vec![format!(
182                    "Changed input: {} -> {}",
183                    id.as_deref().unwrap_or("?"),
184                    uri.as_deref().unwrap_or("?")
185                )]
186            }
187            Change::Follows { input, target } => {
188                // Interleave segments with `.inputs.`: `[a, b, c]` renders as
189                // `a.inputs.b.inputs.c`. Length-1 paths get an `inputs.` prefix.
190                let segments = input.path().segments();
191                let path = if segments.len() == 1 {
192                    format!("inputs.{}", segments[0].render())
193                } else {
194                    let mut out = String::new();
195                    for (i, seg) in segments.iter().enumerate() {
196                        if i == 0 {
197                            out.push_str(&seg.render());
198                        } else {
199                            out.push_str(".inputs.");
200                            out.push_str(&seg.render());
201                        }
202                    }
203                    out
204                };
205                vec![format!(
206                    "Added follows: {}.follows = \"{}\"",
207                    path,
208                    target.to_flake_follows_string()
209                )]
210            }
211            Change::None => vec![],
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn change_id_quoted_dot() {
222        let id = ChangeId::parse("\"hls-1.10\".nixpkgs").unwrap();
223        assert_eq!(id.input().as_str(), "hls-1.10");
224        assert_eq!(id.follows().unwrap().as_str(), "nixpkgs");
225    }
226
227    #[test]
228    fn change_id_single_segment_no_follows() {
229        let id = ChangeId::parse("nixpkgs").unwrap();
230        assert_eq!(id.input().as_str(), "nixpkgs");
231        assert!(id.follows().is_none());
232    }
233
234    #[test]
235    fn success_message_depth_three_has_two_inputs_separators() {
236        let change = Change::Follows {
237            input: ChangeId::parse("neovim.nixvim.flake-parts").unwrap(),
238            target: AttrPath::parse("flake-parts").unwrap(),
239        };
240        let msgs = change.success_messages();
241        assert_eq!(msgs.len(), 1);
242        let msg = &msgs[0];
243        let inputs_count = msg.matches(".inputs.").count();
244        assert_eq!(
245            inputs_count, 2,
246            "depth-3 message should contain exactly two `.inputs.` separators, got: {msg}"
247        );
248    }
249
250    #[test]
251    fn success_message_depth_two_has_one_inputs_separator() {
252        let change = Change::Follows {
253            input: ChangeId::parse("crane.nixpkgs").unwrap(),
254            target: AttrPath::parse("nixpkgs").unwrap(),
255        };
256        let msgs = change.success_messages();
257        let msg = &msgs[0];
258        assert_eq!(msg.matches(".inputs.").count(), 1);
259    }
260
261    #[test]
262    fn success_message_depth_one_uses_inputs_prefix() {
263        let change = Change::Follows {
264            input: ChangeId::parse("nixpkgs").unwrap(),
265            target: AttrPath::parse("foo").unwrap(),
266        };
267        let msgs = change.success_messages();
268        let msg = &msgs[0];
269        assert!(
270            msg.starts_with("Added follows: inputs.nixpkgs.follows ="),
271            "depth-1 message should start with `inputs.<id>.follows =`, got: {msg}"
272        );
273    }
274}