Skip to main content

flake_edit/
change.rs

1use crate::walk::Context;
2
3/// Split an input path at the first `.` that is outside double quotes.
4///
5/// Nix attributes containing dots must be quoted (e.g. `"hls-1.10"`).
6/// A naive `split_once('.')` would split inside the quotes, so we skip
7/// any dot that appears between an opening and closing `"`.
8///
9/// Examples:
10///   `"hls-1.10".nixpkgs` -> `("hls-1.10", "nixpkgs")`
11///   `crane.nixpkgs`      -> `("crane", "nixpkgs")`
12///   `"hls-1.10"`         -> `None`
13///   `nixpkgs`            -> `None`
14pub fn split_quoted_path(s: &str) -> Option<(&str, &str)> {
15    let bytes = s.as_bytes();
16    let mut i = 0;
17    while i < bytes.len() {
18        if bytes[i] == b'"' {
19            // Skip to closing quote
20            i += 1;
21            while i < bytes.len() && bytes[i] != b'"' {
22                i += 1;
23            }
24            // Skip the closing quote itself
25            if i < bytes.len() {
26                i += 1;
27            }
28        } else if bytes[i] == b'.' {
29            return Some((&s[..i], &s[i + 1..]));
30        } else {
31            i += 1;
32        }
33    }
34    None
35}
36
37#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
38pub enum Change {
39    #[default]
40    None,
41    Add {
42        id: Option<String>,
43        uri: Option<String>,
44        // Add an input as a flake.
45        flake: bool,
46    },
47    Remove {
48        ids: Vec<ChangeId>,
49    },
50    Change {
51        id: Option<String>,
52        uri: Option<String>,
53        ref_or_rev: Option<String>,
54    },
55    /// Add a follows relationship to an input.
56    /// Example: `flake-edit follow rust-overlay.nixpkgs nixpkgs`
57    /// Creates: `rust-overlay.inputs.nixpkgs.follows = "nixpkgs";`
58    Follows {
59        /// The input path (e.g., "rust-overlay.nixpkgs" for rust-overlay's nixpkgs input)
60        input: ChangeId,
61        /// The target input to follow (e.g., "nixpkgs")
62        target: String,
63    },
64}
65
66#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
67pub struct ChangeId(String);
68
69impl ChangeId {
70    /// Get the part after the dot (e.g., "nixpkgs" from "poetry2nix.nixpkgs").
71    pub fn follows(&self) -> Option<&str> {
72        split_quoted_path(&self.0).map(|(_, post)| post)
73    }
74
75    /// Get the input part (before the dot, or the whole thing if no dot).
76    pub fn input(&self) -> &str {
77        split_quoted_path(&self.0).map_or(&self.0, |(pre, _)| pre)
78    }
79
80    /// Check if this ChangeId matches the given input and optional follows.
81    fn matches(&self, input: &str, follows: Option<&str>) -> bool {
82        if self.input() != input {
83            return false;
84        }
85        match (self.follows(), follows) {
86            (Some(self_follows), Some(f)) => self_follows == f,
87            (Some(_), None) => false,
88            (None, _) => true,
89        }
90    }
91
92    pub fn matches_with_follows(&self, input: &str, follows: Option<String>) -> bool {
93        self.matches(input, follows.as_deref())
94    }
95
96    /// Match against context. The context carries the input attribute.
97    pub fn matches_with_ctx(&self, follows: &str, ctx: Option<Context>) -> bool {
98        let ctx_input = ctx.and_then(|f| f.level().first().cloned());
99        match ctx_input {
100            Some(input) => self.matches(&input, Some(follows)),
101            None => self.input() == follows,
102        }
103    }
104}
105
106impl std::fmt::Display for ChangeId {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        write!(f, "{}", self.0)
109    }
110}
111
112impl From<String> for ChangeId {
113    fn from(value: String) -> Self {
114        ChangeId(value)
115    }
116}
117
118impl Change {
119    pub fn id(&self) -> Option<ChangeId> {
120        match self {
121            Change::None => None,
122            Change::Add { id, .. } => id.clone().map(|id| id.into()),
123            Change::Remove { ids } => ids.first().cloned(),
124            Change::Change { id, .. } => id.clone().map(|id| id.into()),
125            Change::Follows { input, .. } => Some(input.clone()),
126        }
127    }
128
129    pub fn ids(&self) -> Vec<ChangeId> {
130        match self {
131            Change::Remove { ids } => ids.clone(),
132            Change::Follows { input, .. } => vec![input.clone()],
133            _ => self.id().into_iter().collect(),
134        }
135    }
136    pub fn is_remove(&self) -> bool {
137        matches!(self, Change::Remove { .. })
138    }
139    pub fn is_some(&self) -> bool {
140        !matches!(self, Change::None)
141    }
142    pub fn is_add(&self) -> bool {
143        matches!(self, Change::Add { .. })
144    }
145    pub fn is_change(&self) -> bool {
146        matches!(self, Change::Change { .. })
147    }
148    pub fn is_follows(&self) -> bool {
149        matches!(self, Change::Follows { .. })
150    }
151    pub fn uri(&self) -> Option<&String> {
152        match self {
153            Change::Change { uri, .. } | Change::Add { uri, .. } => uri.as_ref(),
154            _ => None,
155        }
156    }
157    pub fn follows_target(&self) -> Option<&String> {
158        match self {
159            Change::Follows { target, .. } => Some(target),
160            _ => None,
161        }
162    }
163
164    pub fn success_messages(&self) -> Vec<String> {
165        match self {
166            Change::Add { id, uri, .. } => {
167                vec![format!(
168                    "Added input: {} = {}",
169                    id.as_deref().unwrap_or("?"),
170                    uri.as_deref().unwrap_or("?")
171                )]
172            }
173            Change::Remove { ids } => ids
174                .iter()
175                .map(|id| format!("Removed input: {}", id))
176                .collect(),
177            Change::Change { id, uri, .. } => {
178                vec![format!(
179                    "Changed input: {} -> {}",
180                    id.as_deref().unwrap_or("?"),
181                    uri.as_deref().unwrap_or("?")
182                )]
183            }
184            Change::Follows { input, target } => {
185                let msg = if let Some(nested) = input.follows() {
186                    format!(
187                        "Added follows: {}.inputs.{}.follows = \"{}\"",
188                        input.input(),
189                        nested,
190                        target
191                    )
192                } else {
193                    format!(
194                        "Added follows: inputs.{}.follows = \"{}\"",
195                        input.input(),
196                        target
197                    )
198                };
199                vec![msg]
200            }
201            Change::None => vec![],
202        }
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn split_plain() {
212        assert_eq!(
213            split_quoted_path("crane.nixpkgs"),
214            Some(("crane", "nixpkgs"))
215        );
216        assert_eq!(split_quoted_path("nixpkgs"), None);
217    }
218
219    #[test]
220    fn split_quoted_dot_in_name() {
221        assert_eq!(
222            split_quoted_path("\"hls-1.10\".nixpkgs"),
223            Some(("\"hls-1.10\"", "nixpkgs"))
224        );
225        assert_eq!(split_quoted_path("\"hls-1.10\""), None);
226    }
227
228    #[test]
229    fn change_id_quoted_dot() {
230        let id = ChangeId::from("\"hls-1.10\".nixpkgs".to_string());
231        assert_eq!(id.input(), "\"hls-1.10\"");
232        assert_eq!(id.follows(), Some("nixpkgs"));
233    }
234}