1use alloc::collections::{BTreeMap, BTreeSet};
15use core::str;
16use std::io;
17
18use camino::{Utf8Path, Utf8PathBuf};
19use derive_more::Display;
20use schemars::JsonSchema;
21use serde::de::{self, DeserializeOwned};
22use serde::{Deserialize, Deserializer, Serialize, Serializer};
23use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, TomlError, Value, ser};
24
25use crate::digest::{Digest, Fingerprint};
26use crate::errors::{ParseError, Result, TargetError};
27use crate::graph::Enabler;
28use crate::name::{FeatureName, ProfileName, ProfileRef, ScaffoldId, SourceName};
29use crate::part::{Scope, Slot};
30use crate::path::RelPath;
31use crate::profile::{Format, MergeSpec, Policy};
32use crate::source::{Oid, Source, SourceSpec};
33use crate::vars::VarName;
34
35pub(crate) const DIR: &str = ".devset";
37
38pub(crate) const CONFIG: &str = "config.toml";
40
41pub(crate) const LOCK: &str = "lock.toml";
43
44pub(crate) const STATE: &str = "state.toml";
46
47pub(crate) const CONFLICTS: &str = "conflicts";
49
50pub(crate) const PENDING: &str = "pending.toml";
52
53pub(crate) const UNDO: &str = "undo.toml";
55
56pub(crate) const UNDO_BLOBS: &str = "undo";
58
59pub(crate) const BASE: &str = "base";
61
62pub(crate) const ANSWERS: &str = "answers.toml";
64
65#[derive(Debug)]
67pub struct Target {
68 root: Utf8PathBuf,
70 config: Config,
72 config_text: String,
74 lock: Lock,
76 pending: Option<Lock>,
78 state: State,
80 answers: BTreeMap<VarName, String>,
82 given: BTreeSet<VarName>,
84 rescaffold: BTreeSet<ScaffoldId>,
86 state_digest: Option<Digest>,
88}
89
90const SKELETON: &str = "\
92# devset applies the profiles below to this directory, and keeps them up to date.
93# The manual: https://atomix-labs.github.io/devset/
94#
95# Where profiles come from, each named once: a git repository at a ref, or a directory.
96#
97# [sources]
98# atxp = { git = \"https://github.com/atomix-labs/atxp\", tag = \"v0.4.0\" }
99#
100# The profiles to apply, in order, by source and name, with the features to turn on.
101#
102# [[layers]]
103# profile = \"atxp/rust\"
104# features = [\"docs\"]
105";
106
107#[derive(Debug, Default, Deserialize, JsonSchema)]
110#[serde(deny_unknown_fields)]
111pub struct Config {
112 #[serde(default)]
114 pub sources: BTreeMap<SourceName, Source>,
115 #[serde(default)]
117 pub layers: Vec<LayerSpec>,
118 #[serde(default)]
120 pub merge: MergeSpec,
121 #[serde(default)]
123 pub files: BTreeMap<RelPath, Override>,
124}
125
126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
128#[serde(deny_unknown_fields, rename_all = "kebab-case")]
129pub struct LayerSpec {
130 pub profile: ProfileRef,
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
134 pub features: Vec<FeatureName>,
135 #[serde(default = "yes", skip_serializing_if = "is_yes")]
137 pub default_features: bool,
138}
139
140impl LayerSpec {
141 #[must_use]
143 pub const fn new(profile: ProfileRef) -> Self {
144 Self { profile, features: Vec::new(), default_features: true }
145 }
146}
147
148const fn yes() -> bool {
150 true
151}
152
153#[expect(
155 clippy::trivially_copy_pass_by_ref,
156 reason = "serde's `skip_serializing_if` passes a reference"
157)]
158const fn is_yes(flag: &bool) -> bool {
159 *flag
160}
161
162#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165pub struct Override {
166 #[serde(default)]
168 pub from: Option<ProfileName>,
169 #[serde(default)]
171 pub policy: Option<Policy>,
172 #[serde(default)]
174 pub validate: Option<Format>,
175}
176
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(deny_unknown_fields)]
180pub struct Scaffold {
181 pub decision: Scaffolded,
183 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
186 pub files: BTreeMap<RelPath, RelPath>,
187}
188
189#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Display)]
191#[serde(rename_all = "lowercase")]
192#[display(rename_all = "lowercase")]
193pub enum Scaffolded {
194 Written,
196 Found,
198}
199
200impl Target {
201 pub fn find(dir: &Utf8Path) -> Result<Self> {
208 let root = enclosing(dir).ok_or_else(|| TargetError::NotFound { dir: dir.to_owned() })?;
209 Self::load(root)
210 }
211
212 pub fn open_or_new(root: &Utf8Path) -> Result<Self> {
220 match enclosing(root) {
221 Some(found) if found == root => Self::load(root),
222 Some(outer) => {
223 Err(TargetError::Nested { dir: root.to_owned(), root: outer.to_owned() }.into())
224 },
225 None => Ok(Self {
226 root: root.to_owned(),
227 config: Config::default(),
228 config_text: SKELETON.to_owned(),
229 lock: Lock::default(),
230 pending: None,
231 state: State::default(),
232 answers: BTreeMap::new(),
233 given: BTreeSet::new(),
234 rescaffold: BTreeSet::new(),
235 state_digest: None,
236 }),
237 }
238 }
239
240 #[must_use]
242 pub fn root(&self) -> &Utf8Path {
243 &self.root
244 }
245
246 #[must_use]
248 pub const fn config(&self) -> &Config {
249 &self.config
250 }
251
252 #[must_use]
255 pub fn exists(&self) -> bool {
256 self.dir().join(CONFIG).is_file()
257 }
258
259 pub fn add_source(&mut self, name: SourceName, source: Source) -> Result<()> {
267 match self.config.sources.get(&name) {
268 Some(named) if *named == source => return Ok(()),
269 Some(named) => {
270 return Err(TargetError::SourceExists { name, location: named.to_string() }.into());
271 },
272 None => {},
273 }
274 let spec = ser::to_document(&SourceSpec::from(source)).map_err(io::Error::other)?;
275 let inline = spec.into_table().into_inline_table();
276 self.edit("`sources` must be a table", |doc| {
277 let sources = doc.entry("sources").or_insert_with(|| Item::Table(Table::new()));
278 let Some(sources) = sources.as_table_like_mut() else { return false };
279 sources.insert(name.as_str(), Item::Value(Value::InlineTable(inline)));
280 true
281 })
282 }
283
284 pub fn add_layer(&mut self, layer: LayerSpec) -> Result<()> {
291 if self.layer(&layer.profile.profile).is_some() {
292 return Err(TargetError::DuplicateLayer { layer: layer.profile.profile }.into());
293 }
294 if !self.config.sources.contains_key(&layer.profile.source) {
295 let sources = self.config.sources.keys().cloned().collect();
296 let (name, layers) = (layer.profile.source.clone(), vec![layer.profile]);
297 return Err(TargetError::NoSuchSource { name, sources, layers }.into());
298 }
299 let table = ser::to_document(&layer).map_err(io::Error::other)?.into_table();
300 self.edit("`layers` must be a list of tables", |doc| {
301 match doc.entry("layers").or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())) {
302 Item::ArrayOfTables(layers) => layers.push(table),
303 Item::Value(Value::Array(layers)) => layers.push(table.into_inline_table()),
304 Item::None | Item::Value(_) | Item::Table(_) => return false,
305 }
306 true
307 })
308 }
309
310 #[must_use]
312 pub fn layer(&self, name: &ProfileName) -> Option<&LayerSpec> {
313 self.config.layers.iter().find(|layer| layer.profile.profile == *name)
314 }
315
316 pub fn remove_layer(&mut self, name: &ProfileName) -> Result<()> {
325 let Some(index) = self.config.layers.iter().position(|l| l.profile.profile == *name) else {
326 return Ok(());
327 };
328 self.edit("`layers` must be a list of tables", |doc| {
329 match doc.get_mut("layers") {
330 Some(Item::ArrayOfTables(layers)) if index < layers.len() => {
331 layers.remove(index);
332 },
333 Some(Item::Value(Value::Array(layers))) if index < layers.len() => {
334 layers.remove(index);
335 },
336 Some(_) | None => return false,
337 }
338 if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
339 let picks =
340 |item: &Item| item.get("from").and_then(Item::as_str) == Some(name.as_str());
341 let picked: Vec<String> = files
342 .iter()
343 .filter(|(_, item)| picks(item))
344 .map(|(key, _)| key.to_owned())
345 .collect();
346 for key in picked {
347 files.remove(&key);
348 }
349 }
350 true
351 })
352 }
353
354 pub fn remove_override(&mut self, path: &RelPath) -> Result<()> {
359 self.edit("`files` must be a table", |doc| {
360 if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
361 files.remove(path.as_str());
362 }
363 true
364 })
365 }
366
367 fn edit(&mut self, shape: &str, edit: impl FnOnce(&mut DocumentMut) -> bool) -> Result<()> {
372 let file = label(CONFIG);
373 let failed = |text: &str, message: String| ParseError {
374 file: file.clone(),
375 text: text.to_owned(),
376 span: None,
377 message,
378 };
379 let mut doc: DocumentMut = self
380 .config_text
381 .parse()
382 .map_err(|e: TomlError| failed(&self.config_text, e.to_string()))?;
383 let lead = doc.is_empty().then(|| doc.trailing().as_str().map(str::to_owned)).flatten();
384 if !edit(&mut doc) {
385 return Err(failed(&self.config_text, shape.to_owned()).into());
386 }
387 if let Some(lead) = lead.filter(|lead| !lead.trim().is_empty()) {
388 doc.set_trailing("");
389 if let Some((_, first)) = doc.iter_mut().next() {
390 let prefix = format!("{}\n\n", lead.trim_end());
391 match first {
392 Item::Table(table) => table.decor_mut().set_prefix(prefix),
393 Item::ArrayOfTables(tables) => {
394 if let Some(table) = tables.get_mut(0) {
395 table.decor_mut().set_prefix(prefix);
396 }
397 },
398 Item::Value(_) | Item::None => {},
399 }
400 }
401 }
402 let text = doc.to_string();
403 self.config = from_toml(text.as_bytes(), &file)?;
404 self.config_text = text;
405 Ok(())
406 }
407
408 pub fn answer(&mut self, name: VarName, value: String) {
410 self.given.insert(name.clone());
411 self.answers.insert(name, value);
412 }
413
414 #[must_use]
416 pub const fn answers(&self) -> &BTreeMap<VarName, String> {
417 &self.answers
418 }
419
420 pub fn rescaffold(&mut self, scaffold: ScaffoldId) {
423 self.rescaffold.insert(scaffold);
424 }
425
426 pub(crate) const fn rescaffolds(&self) -> &BTreeSet<ScaffoldId> {
428 &self.rescaffold
429 }
430
431 pub(crate) const fn scaffolds(&self) -> &BTreeMap<ScaffoldId, Scaffold> {
433 &self.state.scaffolds
434 }
435
436 pub(crate) fn given(&self) -> impl Iterator<Item = &VarName> {
438 self.given.iter()
439 }
440
441 pub(crate) fn dir(&self) -> Utf8PathBuf {
443 self.root.join(DIR)
444 }
445
446 pub(crate) fn config_text(&self) -> &str {
448 &self.config_text
449 }
450
451 fn lock(&self) -> &Lock {
453 self.pending.as_ref().unwrap_or(&self.lock)
454 }
455
456 #[must_use]
460 pub fn pinned(&self, source: &Source) -> Option<&Oid> {
461 let locked = self.lock().profiles.iter();
462 locked.filter(|locked| locked.source == *source).find_map(|locked| locked.rev.as_ref())
463 }
464
465 pub(crate) fn locked(&self, source: &Source, name: &ProfileName) -> Option<Digest> {
467 let lock = self.lock();
468 let locked = lock.profiles.iter().find(|l| l.source == *source && l.name == *name);
469 locked.map(|locked| locked.digest)
470 }
471
472 pub(crate) fn sidecar(&self, path: &RelPath) -> Utf8PathBuf {
474 path.under(&self.dir().join(CONFLICTS))
475 }
476
477 pub(crate) fn unfinished(&self) -> Utf8PathBuf {
482 self.dir().join(CONFLICTS).join(DIR)
483 }
484
485 pub(crate) fn base(&self, record: &Fingerprint) -> Result<Option<Vec<u8>>> {
487 let blob = read_optional(&self.dir().join(BASE).join(record.exact.to_string()))?;
488 Ok(blob.filter(|bytes| Digest::of(bytes) == record.exact))
489 }
490
491 pub(crate) fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
493 self.state.records()
494 }
495
496 pub(crate) const fn state_digest(&self) -> Option<Digest> {
498 self.state_digest
499 }
500
501 fn load(root: &Utf8Path) -> Result<Self> {
503 let dir = root.join(DIR);
504 let config_raw = fs_err::read(dir.join(CONFIG))?;
505 let config = read_config(&config_raw)?;
506 let config_text = String::from_utf8(config_raw).map_err(io::Error::other)?;
507 let state_raw = read_optional(&dir.join(STATE))?;
508 let state_digest = state_raw.as_deref().map(Digest::of);
509 let state = state_raw.map(|raw| from_toml(&raw, &label(STATE))).transpose()?;
510 let lock = |name: &str| -> Result<Option<Lock>> {
511 let raw = read_optional(&dir.join(name))?;
512 raw.map(|raw| Lock::read(&raw, &label(name))).transpose()
513 };
514 Ok(Self {
515 root: root.to_owned(),
516 config,
517 config_text,
518 lock: lock(LOCK)?.unwrap_or_default(),
519 pending: lock(&format!("{CONFLICTS}/{DIR}/{PENDING}"))?,
520 state: state.unwrap_or_default(),
521 state_digest,
522 answers: read_toml(&dir, ANSWERS)?.unwrap_or_default(),
523 given: BTreeSet::new(),
524 rescaffold: BTreeSet::new(),
525 })
526 }
527}
528
529fn read_config(raw: &[u8]) -> Result<Config> {
531 let file = label(CONFIG);
532 let table: toml::Table = from_toml(raw, &file)?;
533 let located = |layer: &toml::Value| {
534 layer
535 .as_table()
536 .is_some_and(|layer| layer.contains_key("git") || layer.contains_key("path"))
537 };
538 let layers = table.get("layers").and_then(toml::Value::as_array);
539 if layers.is_some_and(|layers| layers.iter().any(located)) {
540 return Err(TargetError::OldConfig.into());
541 }
542 from_toml(raw, &file)
543}
544
545pub(crate) fn label(name: &str) -> String {
547 format!("{DIR}/{name}")
548}
549
550fn enclosing(dir: &Utf8Path) -> Option<&Utf8Path> {
552 dir.ancestors().find(|d| d.join(DIR).join(CONFIG).is_file())
553}
554
555#[derive(Debug, Default, Serialize, Deserialize)]
557#[serde(deny_unknown_fields)]
558pub(crate) struct Lock {
559 version: V2,
561 #[serde(default, skip_serializing_if = "Vec::is_empty")]
563 profiles: Vec<Locked>,
564}
565
566#[derive(Debug, Serialize, Deserialize)]
568#[serde(deny_unknown_fields)]
569pub(crate) struct Locked {
570 pub name: ProfileName,
572 #[serde(default, skip_serializing_if = "Option::is_none")]
574 pub rev: Option<Oid>,
575 pub digest: Digest,
577 pub source: Source,
579 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
581 pub features: BTreeMap<FeatureName, Vec<String>>,
582}
583
584impl Locked {
585 pub(crate) fn features(
587 features: &BTreeMap<FeatureName, BTreeSet<Enabler>>,
588 ) -> BTreeMap<FeatureName, Vec<String>> {
589 features
590 .iter()
591 .map(|(feature, by)| (feature.clone(), by.iter().map(ToString::to_string).collect()))
592 .collect()
593 }
594}
595
596impl Lock {
597 pub(crate) const fn new(profiles: Vec<Locked>) -> Self {
599 Self { version: V2, profiles }
600 }
601
602 fn read(raw: &[u8], file: &str) -> Result<Self> {
604 let table: toml::Table = from_toml(raw, file)?;
605 if table.get("version").and_then(toml::Value::as_integer) == Some(1) {
606 return Ok(Self::default());
607 }
608 from_toml(raw, file)
609 }
610}
611
612#[derive(Debug, Default, Serialize, Deserialize)]
614#[serde(deny_unknown_fields)]
615pub(crate) struct State {
616 version: V2,
618 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
620 scaffolds: BTreeMap<ScaffoldId, Scaffold>,
621 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
623 files: BTreeMap<RelPath, FileRecord>,
624 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
626 parts: BTreeMap<RelPath, BTreeMap<String, PartRecord>>,
627}
628
629#[derive(Clone, Copy, Debug)]
631pub(crate) struct Record {
632 pub scope: Scope,
634 pub fingerprint: Fingerprint,
636 pub policy: Policy,
638}
639
640#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
642#[serde(deny_unknown_fields)]
643struct FileRecord {
644 exact: Digest,
646 canonical: Digest,
648 #[serde(default, skip_serializing_if = "Policy::is_owned")]
650 policy: Policy,
651}
652
653#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
655#[serde(deny_unknown_fields)]
656struct PartRecord {
657 scope: Scope,
659 exact: Digest,
661 canonical: Digest,
663 #[serde(default, skip_serializing_if = "Policy::is_owned")]
665 policy: Policy,
666}
667
668impl State {
669 pub(crate) fn new(
671 records: BTreeMap<Slot, Record>, scaffolds: BTreeMap<ScaffoldId, Scaffold>,
672 ) -> Self {
673 let mut state = Self { scaffolds, ..Self::default() };
674 for (Slot { path, part }, Record { scope, fingerprint, policy }) in records {
675 let Fingerprint { exact, canonical } = fingerprint;
676 match part {
677 None => {
678 state.files.insert(path, FileRecord { exact, canonical, policy });
679 },
680 Some(owner) => {
681 let record = PartRecord { scope, exact, canonical, policy };
682 state.parts.entry(path).or_default().insert(owner, record);
683 },
684 }
685 }
686 state
687 }
688
689 fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
691 let files = self.files.iter().map(|(path, record)| {
692 let FileRecord { exact, canonical, policy } = *record;
693 let fingerprint = Fingerprint { exact, canonical };
694 (Slot::whole(path.clone()), Record { scope: Scope::File, fingerprint, policy })
695 });
696 let parts = self.parts.iter().flat_map(|(path, parts)| {
697 parts.iter().map(|(owner, record)| {
698 let slot = Slot { path: path.clone(), part: Some(owner.clone()) };
699 let PartRecord { scope, exact, canonical, policy } = *record;
700 (slot, Record { scope, fingerprint: Fingerprint { exact, canonical }, policy })
701 })
702 });
703 files.chain(parts)
704 }
705
706 pub(crate) fn bases(&self) -> impl Iterator<Item = Digest> {
708 self.records().map(|(_, record)| record.fingerprint.exact)
709 }
710}
711
712#[derive(Clone, Copy, Debug, Default)]
714pub(crate) struct V1;
715
716impl Serialize for V1 {
717 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
718 serializer.serialize_u8(1)
719 }
720}
721
722impl<'de> Deserialize<'de> for V1 {
723 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
724 match u8::deserialize(deserializer)? {
725 1 => Ok(Self),
726 other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
727 }
728 }
729}
730
731#[derive(Clone, Copy, Debug, Default)]
733pub(crate) struct V2;
734
735impl Serialize for V2 {
736 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
737 serializer.serialize_u8(2)
738 }
739}
740
741impl<'de> Deserialize<'de> for V2 {
742 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
743 match u8::deserialize(deserializer)? {
744 1 | 2 => Ok(Self),
745 other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
746 }
747 }
748}
749
750pub(crate) fn from_toml<T: DeserializeOwned>(bytes: &[u8], file: &str) -> Result<T> {
752 let failed = |text: &str, span, message| ParseError {
753 file: file.into(),
754 text: text.into(),
755 span,
756 message,
757 };
758 let text = str::from_utf8(bytes).map_err(|e| failed("", None, e.to_string()))?;
759 Ok(toml::from_str(text).map_err(|e| failed(text, e.span(), e.message().to_owned()))?)
760}
761
762pub(crate) fn read_toml<T: DeserializeOwned>(dir: &Utf8Path, name: &str) -> Result<Option<T>> {
764 read_optional(&dir.join(name))?.map(|raw| from_toml(&raw, &label(name))).transpose()
765}
766
767pub(crate) fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>> {
769 match fs_err::read(path) {
770 Ok(bytes) => Ok(Some(bytes)),
771 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
772 Err(e) => Err(e.into()),
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::{LayerSpec, Lock, State, Target};
779 use crate::errors::{Error, TargetError};
780 use crate::source::Source;
781
782 #[test]
783 fn a_new_target_keeps_its_skeleton_above_what_is_added() {
784 let dir = camino_tempfile::tempdir().expect("a directory");
785 let mut target = Target::open_or_new(dir.path()).expect("a new target");
786 target
787 .add_source("house".parse().expect("a name"), Source::Dir("../p".into()))
788 .expect("added");
789 target.add_layer(LayerSpec::new("house/base".parse().expect("a layer"))).expect("added");
790 let text = target.config_text();
791 assert!(text.starts_with("# devset applies"), "the skeleton leads: {text}");
792 assert!(
793 text.contains("\n\n[sources]\nhouse = { path = \"../p\" }\n"),
794 "a source, inline: {text}"
795 );
796 assert!(text.ends_with("[[layers]]\nprofile = \"house/base\"\n"), "then the layer: {text}");
797 let same = target.add_source("house".parse().expect("a name"), Source::Dir("../p".into()));
798 assert!(same.is_ok(), "naming a source again as it is changes nothing");
799 let other = target.add_source("house".parse().expect("a name"), Source::Dir("../q".into()));
800 assert!(matches!(other, Err(Error::Target(TargetError::SourceExists { .. }))), "{other:?}");
801 let twice = target.add_layer(LayerSpec::new("house/base".parse().expect("a layer")));
802 assert!(
803 matches!(twice, Err(Error::Target(TargetError::DuplicateLayer { .. }))),
804 "{twice:?}"
805 );
806 let unknown = target.add_layer(LayerSpec::new("nope/other".parse().expect("a layer")));
807 assert!(
808 matches!(unknown, Err(Error::Target(TargetError::NoSuchSource { .. }))),
809 "{unknown:?}"
810 );
811 }
812
813 #[test]
814 fn devset_0_1_records() {
815 let dir = camino_tempfile::tempdir().expect("a directory");
816 let devset = dir.path().join(".devset");
817 fs_err::create_dir_all(&devset).expect("a directory");
818 fs_err::write(devset.join("config.toml"), "[[layers]]\npath = \"../p\"\n")
819 .expect("written");
820 let old = Target::find(dir.path()).expect_err("an old config");
821 assert!(matches!(old, Error::Target(TargetError::OldConfig)), "refused: {old}");
822 let lock =
823 Lock::read(b"version = 1\n[[layers]]\ndigest = \"x\"\n", "lock.toml").expect("read");
824 assert!(lock.profiles.is_empty(), "an old lock pins nothing");
825 let state: State = toml::from_str(
826 "version = 1\n[files.\"a\"]\nexact = \"0000000000000000000000000000000000000000000000000000000000000000\"\ncanonical = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
827 )
828 .expect("an old state reads");
829 assert_eq!(state.records().count(), 1, "with its records");
830 let written = toml::to_string(&state).expect("written");
831 assert!(written.starts_with("version = 2\n"), "as version 2: {written}");
832 }
833}