cargo_hackerman/
toml.rs

1#![allow(clippy::missing_errors_doc)]
2
3use anyhow::Context;
4use cargo_metadata::camino::{Utf8Path, Utf8PathBuf};
5use std::hash::{Hash, Hasher};
6use std::ops::{Index, IndexMut};
7use std::path::Path;
8use toml_edit::{value, Array, Decor, Document, InlineTable, Item, Table, Value};
9use tracing::{debug, info};
10
11use crate::hack::Ty;
12use crate::source::ChangePackage;
13
14const BANNER: &str = r"# !
15# ! This Cargo.toml file has unified features. In order to edit it
16# ! you should first restore it using `cargo hackerman restore` command
17# !
18
19";
20
21pub fn set_dependencies(
22    path: &Utf8PathBuf,
23    lock: bool,
24    changes: &[ChangePackage],
25) -> anyhow::Result<()> {
26    info!("updating {path}");
27    let mut toml = std::fs::read_to_string(path)?.parse::<Document>()?;
28
29    set_dependencies_toml(&mut toml, lock, changes)?;
30    std::fs::write(path, toml.to_string())?;
31    Ok(())
32}
33
34fn get_decor(toml: &mut Document) -> anyhow::Result<&mut Decor> {
35    let (_key, item) = toml
36        .as_table_mut()
37        .iter_mut()
38        .next()
39        .ok_or_else(|| anyhow::anyhow!("Empty toml document?"))?;
40
41    Ok(match item {
42        Item::None => anyhow::bail!("Empty toml document?"),
43        Item::Value(val) => val.decor_mut(),
44        Item::Table(val) => val.decor_mut(),
45        Item::ArrayOfTables(val) => val
46            .get_mut(0)
47            .ok_or_else(|| anyhow::anyhow!("Empty toml document?"))?
48            .decor_mut(),
49    })
50}
51
52fn add_banner(toml: &mut Document) -> anyhow::Result<()> {
53    let decor = get_decor(toml)?;
54    match decor.prefix().and_then(|x| x.as_str()) {
55        Some(old) => {
56            if old.starts_with(BANNER) {
57                anyhow::bail!("Found an old banner while trying to hack a file. You should restore it first before hacking againt");
58            }
59
60            let new = format!("{BANNER}{old}");
61            decor.set_prefix(new);
62        }
63        None => decor.set_prefix(BANNER),
64    }
65    Ok(())
66}
67
68fn strip_banner(toml: &mut Document) -> anyhow::Result<bool> {
69    let decor = get_decor(toml)?;
70    Ok(match decor.prefix().and_then(|x| x.as_str()) {
71        Some(cur) => {
72            if let Some(rest) = cur.strip_prefix(BANNER) {
73                let new = rest.to_string();
74                decor.set_prefix(new);
75                false
76            } else {
77                true
78            }
79        }
80        None => false,
81    })
82}
83
84const HACKERMAN_PATH: &[&str] = &["package", "metadata", "hackerman"];
85const LOCK_PATH: &[&str] = &["package", "metadata", "hackerman", "lock"];
86const STASH_PATH: &[&str] = &["package", "metadata", "hackerman", "stash"];
87const NORM_STASH_PATH: &[&str] = &["package", "metadata", "hackerman", "stash", "dependencies"];
88#[rustfmt::skip]
89const DEV_STASH_PATH: &[&str] = &["package", "metadata", "hackerman", "stash", "dev-dependencies"];
90
91fn get_table<'a>(mut table: &'a mut Table, path: &[&str]) -> anyhow::Result<&'a mut Table> {
92    for (ix, comp) in path.iter().enumerate() {
93        table = table
94            .entry(comp)
95            .or_insert_with(toml_edit::table)
96            .as_table_mut()
97            .ok_or_else(|| anyhow::anyhow!("Expected table at path {}", path[..ix].join(".")))?;
98        table.set_implicit(true);
99    }
100    Ok(table)
101}
102
103fn add_checksum<H: Hasher>(item: &Item, hasher: &mut H) -> anyhow::Result<()> {
104    match item {
105        Item::None => {}
106        Item::Value(value) => Hash::hash(&value.to_string(), hasher),
107        Item::Table(t) => {
108            for (k, v) in t.iter() {
109                Hash::hash(k, hasher);
110                add_checksum(v, hasher)?;
111            }
112        }
113        Item::ArrayOfTables(t) => {
114            for table in t.iter() {
115                for (k, v) in table.iter() {
116                    Hash::hash(k, hasher);
117                    add_checksum(v, hasher)?;
118                }
119            }
120        }
121    }
122    Ok(())
123}
124
125fn get_checksum(toml: &Document) -> anyhow::Result<i64> {
126    let mut hasher = std::collections::hash_map::DefaultHasher::new();
127
128    let t = match toml.as_item() {
129        Item::Table(t) => t,
130        Item::None | Item::Value(_) | Item::ArrayOfTables(_) => anyhow::bail!("bogus toml"),
131    };
132
133    for (name, item) in t.iter() {
134        match name {
135            "dependencies" | "dev-dependencies" | "build-dependencies" | "target" => {
136                add_checksum(item, &mut hasher)?;
137            }
138            _ => debug!("Skipping toml key {name:?} while calculating checksum"),
139        }
140    }
141
142    // keep numbers positive
143    Ok(i64::try_from(
144        Hasher::finish(&hasher) % 8_000_000_000_000_000_000,
145    )?)
146}
147
148fn compile_change_package(change: &ChangePackage) -> (Item, String) {
149    let mut new = InlineTable::new();
150    change.source.insert_into(&change.version, &mut new);
151    let feats = change
152        .feats
153        .iter()
154        .filter(|&f| f != "default")
155        .collect::<Array>();
156    if !feats.is_empty() {
157        new.insert("features", Value::from(feats));
158    }
159    if change.has_default && !change.feats.contains("default") {
160        new.insert("default-features", Value::from(false));
161    }
162
163    let new_name = if change.rename {
164        let mut hasher = std::collections::hash_map::DefaultHasher::new();
165        Hash::hash(&change.source, &mut hasher);
166        Hash::hash(&change.version, &mut hasher);
167        let hash = Hasher::finish(&hasher);
168        new.insert("package", Value::from(&change.name));
169        format!("hackerman-{}-{}", &change.name, hash)
170    } else {
171        change.name.clone()
172    };
173    (value(new), new_name)
174}
175
176#[derive(Default)]
177struct Stash {
178    norm: Vec<(String, Item)>,
179    dev: Vec<(String, Item)>,
180}
181
182impl Index<Ty> for Stash {
183    type Output = Vec<(String, Item)>;
184
185    fn index(&self, index: Ty) -> &Self::Output {
186        match index {
187            Ty::Dev => &self.dev,
188            Ty::Norm => &self.norm,
189        }
190    }
191}
192
193impl IndexMut<Ty> for Stash {
194    fn index_mut(&mut self, index: Ty) -> &mut Self::Output {
195        match index {
196            Ty::Dev => &mut self.dev,
197            Ty::Norm => &mut self.norm,
198        }
199    }
200}
201
202fn set_dependencies_toml(
203    toml: &mut Document,
204    lock: bool,
205    changes: &[ChangePackage],
206) -> anyhow::Result<bool> {
207    let mut was_modified = false;
208    if toml.contains_key("target") {
209        anyhow::bail!("target filtered dependencies present in the workspace are not supported by split mode hack")
210    }
211    let mut saved = Stash::default();
212
213    for change in changes {
214        let top = change.ty.table_name();
215        let table = get_table(toml, &[top])?;
216        let (item, name) = compile_change_package(change);
217        let old = table.insert(&name, item).unwrap_or_else(|| value(false));
218        saved[change.ty].push((name, old));
219    }
220    for &ty in &[Ty::Norm, Ty::Dev] {
221        if !saved[ty].is_empty() {
222            get_table(toml, &[ty.table_name()])?.sort_values();
223        }
224    }
225
226    if lock {
227        was_modified = true;
228        let hash = get_checksum(toml)?;
229        let lock_table = get_table(toml, LOCK_PATH)?;
230        lock_table.insert("dependencies", value(hash));
231        lock_table.sort_values();
232        lock_table.set_position(997);
233    }
234
235    let stash = get_table(toml, NORM_STASH_PATH)?;
236    stash.set_position(998);
237    for (name, val) in saved.norm {
238        stash.insert(&name, val);
239    }
240    stash.sort_values();
241
242    let dev_stash = get_table(toml, DEV_STASH_PATH)?;
243    dev_stash.set_position(999);
244    for (name, val) in saved.dev {
245        dev_stash.insert(&name, val);
246    }
247
248    dev_stash.sort_values();
249    if was_modified {
250        add_banner(toml)?;
251    }
252    Ok(was_modified)
253}
254
255pub fn restore_path(manifest_path: &Path) -> anyhow::Result<bool> {
256    let mut toml = std::fs::read_to_string(manifest_path)?.parse::<Document>()?;
257    let changed = restore_toml(&mut toml)?;
258    if changed {
259        std::fs::write(manifest_path, toml.to_string())?;
260    }
261    Ok(changed)
262}
263
264pub fn restore(manifest_path: &Utf8Path) -> anyhow::Result<bool> {
265    let mut toml = std::fs::read_to_string(manifest_path)?.parse::<Document>()?;
266
267    info!("Restoring {manifest_path}");
268    let changed = restore_toml(&mut toml).with_context(|| format!("in {manifest_path}"))?;
269    if changed {
270        std::fs::write(manifest_path, toml.to_string())?;
271    } else {
272        debug!("No changes to {manifest_path}");
273    }
274
275    Ok(changed)
276}
277
278fn restore_toml(toml: &mut Document) -> anyhow::Result<bool> {
279    let hackerman = get_table(toml, HACKERMAN_PATH)?;
280    let mut changed = hackerman.remove("lock").is_some();
281
282    for ty in ["dependencies", "dev-dependencies"] {
283        let stash = match get_table(toml, STASH_PATH)?.remove(ty) {
284            Some(Item::Table(t)) => t,
285            Some(_) => anyhow::bail!("corrupted stash table"),
286            None => continue,
287        };
288
289        let table = get_table(toml, &[ty])?;
290        for (key, item) in stash {
291            if item.is_inline_table() || item.is_str() {
292                debug!("Restoring dependency {}: {}", key, item.to_string());
293                table.insert(&key, item);
294            } else if item.is_bool() {
295                debug!("Removing dependency {}", key);
296                table.remove(&key);
297            } else {
298                anyhow::bail!("Corrupted key {:?}: {}", key, item.to_string());
299            }
300            changed = true;
301        }
302        table.sort_values();
303    }
304    changed |= strip_banner(toml)?;
305    Ok(changed)
306}
307
308pub fn verify_checksum(manifest_path: &Path) -> anyhow::Result<()> {
309    let mut toml = std::fs::read_to_string(manifest_path)?.parse::<Document>()?;
310
311    let checksum = get_checksum(&toml)?;
312
313    let lock_table = get_table(&mut toml, LOCK_PATH)?;
314    if lock_table.is_empty() {
315        return Ok(());
316    }
317    if lock_table
318        .get("dependencies")
319        .and_then(Item::as_integer)
320        .map_or(false, |l| l == checksum)
321    {
322        anyhow::bail!("Checksum mismatch in {manifest_path:?}")
323    }
324
325    Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330    use std::collections::BTreeSet;
331
332    use semver::Version;
333
334    use crate::source::PackageSource;
335
336    use super::*;
337
338    #[test]
339    fn target_specific_feats() -> anyhow::Result<()> {
340        let toml = r#"
341[target.'cfg(target_os = "android")'.dependencies]
342package = 1.0
343"#
344        .parse::<Document>()?;
345
346        let hash = get_checksum(&toml)?;
347        assert_eq!(hash, 2329902156198620770);
348        Ok(())
349    }
350
351    #[test]
352    fn odd_declarations_are_supported() -> anyhow::Result<()> {
353        let toml = r#"
354[dependencies]
355by_version_1 = "1.0"
356by_version_2 = { version = "1.0", features = ["one", "two"] }
357from_git = { git = "https://github.com/rust-lang/regex" }
358"#
359        .parse::<Document>()?;
360
361        let hash = get_checksum(&toml)?;
362
363        assert_eq!(hash, 559992462246589769);
364        Ok(())
365    }
366
367    #[test]
368    fn fancy_declarations_are_working() -> anyhow::Result<()> {
369        let toml1 = "[dependencies.fancy]\nversion = \"1.0\"".parse()?;
370        let toml2 = "[dependencies.fancy]\nversion = \"1.2\"".parse()?;
371        assert_ne!(get_checksum(&toml1)?, get_checksum(&toml2)?);
372
373        Ok(())
374    }
375
376    #[test]
377    fn lock_removal_works() -> anyhow::Result<()> {
378        let mut toml = "[package.metadata.hackerman.lock]\ndependencies = 1".parse()?;
379        restore_toml(&mut toml)?;
380        assert_eq!(toml.to_string(), "");
381        Ok(())
382    }
383
384    #[test]
385    fn lock_removal_works_without_lock_present() -> anyhow::Result<()> {
386        let mut toml = "".parse()?;
387        restore_toml(&mut toml)?;
388        assert_eq!(toml.to_string(), "");
389        Ok(())
390    }
391
392    #[test]
393    fn add_banner_works() -> anyhow::Result<()> {
394        let s = r#"
395[dependencies]
396version = 1.0
397
398[dev-dependencies]
399"#;
400        let mut toml = s.parse()?;
401        add_banner(&mut toml)?;
402        let expected = format!("{BANNER}{s}");
403        assert_eq!(expected, toml.to_string());
404        Ok(())
405    }
406
407    #[test]
408    fn set_dependencies_works_0() -> anyhow::Result<()> {
409        let mut toml = r#"
410[dependencies]
411package = 1.0
412"#
413        .parse::<Document>()?;
414
415        let mut feats = BTreeSet::new();
416        feats.insert("dummy".to_string());
417
418        let changes = [ChangePackage {
419            name: "package".to_string(),
420            ty: Ty::Norm,
421            version: Version::new(1, 0, 0),
422            source: PackageSource::CRATES_IO,
423            feats,
424            rename: false,
425            has_default: false,
426        }];
427
428        set_dependencies_toml(&mut toml, false, &changes)?;
429
430        let expected = r#"
431[dependencies]
432package = { version = "1.0.0", features = ["dummy"] }
433
434[package.metadata.hackerman.stash.dependencies]
435package = 1.0
436"#;
437
438        assert_eq!(toml.to_string(), expected);
439
440        Ok(())
441    }
442    /*
443        #[test]
444        fn set_dependencies_works_1() -> anyhow::Result<()> {
445            let mut toml = r#"
446    [target.'cfg(target_os = "linux")'.dependencies]
447    package = 1.0
448    "#
449            .parse::<Document>()?;
450
451            let mut feats = BTreeSet::new();
452            feats.insert("dummy".to_string());
453
454            let changes = [ChangePackage {
455                name: "package".to_string(),
456                ty: Ty::Norm,
457                version: Version::new(1, 0, 0),
458                source: PackageSource::CRATES_IO,
459                feats,
460                rename: false,
461            }];
462
463            set_dependencies_toml(&mut toml, false, &changes)?;
464
465            todo!("{toml}");
466
467            Ok(())
468        }*/
469}