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::{Array, ArrayOfTables, DocumentMut, Item, Table, TableLike, 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 turn_on(
363 &mut self, name: &ProfileName, features: &[FeatureName], defaults: Option<bool>,
364 ) -> Result<(Vec<FeatureName>, Option<bool>)> {
365 let (index, layer) = self.find_layer(name)?;
366 let mut listed = layer.features.clone();
367 let added: Vec<FeatureName> =
368 features.iter().filter(|feature| !listed.contains(feature)).cloned().collect();
369 listed.extend(added.iter().cloned());
370 let defaults = defaults.filter(|&on| on != layer.default_features);
371 if !added.is_empty() || defaults.is_some() {
372 self.edit_layer(index, |table| {
373 set_features(table, &listed);
374 match defaults {
375 Some(true) => {
376 table.remove("default-features");
377 },
378 Some(false) => {
379 table.insert("default-features", toml_edit::value(false));
380 },
381 None => {},
382 }
383 })?;
384 }
385 Ok((added, defaults))
386 }
387
388 pub fn turn_off(&mut self, name: &ProfileName, features: &[FeatureName]) -> Result<()> {
396 let (index, layer) = self.find_layer(name)?;
397 if let Some(feature) = features.iter().find(|feature| !layer.features.contains(feature)) {
398 let (feature, listed) = (feature.clone(), layer.features.clone());
399 return Err(TargetError::NotListed { layer: name.clone(), feature, listed }.into());
400 }
401 let listed: Vec<FeatureName> =
402 layer.features.into_iter().filter(|feature| !features.contains(feature)).collect();
403 self.edit_layer(index, |table| set_features(table, &listed))
404 }
405
406 fn find_layer(&self, name: &ProfileName) -> Result<(usize, LayerSpec)> {
408 let layers = &self.config.layers;
409 let found = layers.iter().enumerate().find(|(_, layer)| layer.profile.profile == *name);
410 found.map(|(index, layer)| (index, layer.clone())).ok_or_else(|| {
411 let names = layers.iter().map(|layer| layer.profile.profile.to_string()).collect();
412 TargetError::NoSuchLayer { name: name.to_string(), names }.into()
413 })
414 }
415
416 fn edit_layer(&mut self, index: usize, edit: impl FnOnce(&mut dyn TableLike)) -> Result<()> {
418 self.edit("`layers` must be a list of tables", |doc| {
419 let table: &mut dyn TableLike = match doc.get_mut("layers") {
420 Some(Item::ArrayOfTables(layers)) => match layers.get_mut(index) {
421 Some(layer) => layer,
422 None => return false,
423 },
424 Some(Item::Value(Value::Array(layers))) => {
425 match layers.get_mut(index).and_then(Value::as_inline_table_mut) {
426 Some(layer) => layer,
427 None => return false,
428 }
429 },
430 Some(_) | None => return false,
431 };
432 edit(table);
433 true
434 })
435 }
436
437 pub fn remove_override(&mut self, path: &RelPath) -> Result<()> {
442 self.edit("`files` must be a table", |doc| {
443 if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
444 files.remove(path.as_str());
445 }
446 true
447 })
448 }
449
450 fn edit(&mut self, shape: &str, edit: impl FnOnce(&mut DocumentMut) -> bool) -> Result<()> {
455 let file = label(CONFIG);
456 let failed = |text: &str, message: String| ParseError {
457 file: file.clone(),
458 text: text.to_owned(),
459 span: None,
460 message,
461 };
462 let mut doc: DocumentMut = self
463 .config_text
464 .parse()
465 .map_err(|e: TomlError| failed(&self.config_text, e.to_string()))?;
466 let lead = doc.is_empty().then(|| doc.trailing().as_str().map(str::to_owned)).flatten();
467 if !edit(&mut doc) {
468 return Err(failed(&self.config_text, shape.to_owned()).into());
469 }
470 if let Some(lead) = lead.filter(|lead| !lead.trim().is_empty()) {
471 doc.set_trailing("");
472 if let Some((_, first)) = doc.iter_mut().next() {
473 let prefix = format!("{}\n\n", lead.trim_end());
474 match first {
475 Item::Table(table) => table.decor_mut().set_prefix(prefix),
476 Item::ArrayOfTables(tables) => {
477 if let Some(table) = tables.get_mut(0) {
478 table.decor_mut().set_prefix(prefix);
479 }
480 },
481 Item::Value(_) | Item::None => {},
482 }
483 }
484 }
485 let text = doc.to_string();
486 self.config = from_toml(text.as_bytes(), &file)?;
487 self.config_text = text;
488 Ok(())
489 }
490
491 pub fn answer(&mut self, name: VarName, value: String) {
493 self.given.insert(name.clone());
494 self.answers.insert(name, value);
495 }
496
497 #[must_use]
499 pub const fn answers(&self) -> &BTreeMap<VarName, String> {
500 &self.answers
501 }
502
503 pub fn rescaffold(&mut self, scaffold: ScaffoldId) {
506 self.rescaffold.insert(scaffold);
507 }
508
509 pub(crate) const fn rescaffolds(&self) -> &BTreeSet<ScaffoldId> {
511 &self.rescaffold
512 }
513
514 pub(crate) const fn scaffolds(&self) -> &BTreeMap<ScaffoldId, Scaffold> {
516 &self.state.scaffolds
517 }
518
519 pub(crate) fn given(&self) -> impl Iterator<Item = &VarName> {
521 self.given.iter()
522 }
523
524 pub(crate) fn dir(&self) -> Utf8PathBuf {
526 self.root.join(DIR)
527 }
528
529 pub(crate) fn config_text(&self) -> &str {
531 &self.config_text
532 }
533
534 fn lock(&self) -> &Lock {
536 self.pending.as_ref().unwrap_or(&self.lock)
537 }
538
539 #[must_use]
543 pub fn pinned(&self, source: &Source) -> Option<&Oid> {
544 let locked = self.lock().profiles.iter();
545 locked.filter(|locked| locked.source == *source).find_map(|locked| locked.rev.as_ref())
546 }
547
548 pub(crate) fn locked(&self, source: &Source, name: &ProfileName) -> Option<Digest> {
550 let lock = self.lock();
551 let locked = lock.profiles.iter().find(|l| l.source == *source && l.name == *name);
552 locked.map(|locked| locked.digest)
553 }
554
555 pub(crate) fn sidecar(&self, path: &RelPath) -> Utf8PathBuf {
557 path.under(&self.dir().join(CONFLICTS))
558 }
559
560 pub(crate) fn unfinished(&self) -> Utf8PathBuf {
565 self.dir().join(CONFLICTS).join(DIR)
566 }
567
568 pub(crate) fn base(&self, record: &Fingerprint) -> Result<Option<Vec<u8>>> {
570 let blob = read_optional(&self.dir().join(BASE).join(record.exact.to_string()))?;
571 Ok(blob.filter(|bytes| Digest::of(bytes) == record.exact))
572 }
573
574 pub(crate) fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
576 self.state.records()
577 }
578
579 pub(crate) const fn state_digest(&self) -> Option<Digest> {
581 self.state_digest
582 }
583
584 fn load(root: &Utf8Path) -> Result<Self> {
586 let dir = root.join(DIR);
587 let config_raw = fs_err::read(dir.join(CONFIG))?;
588 let config = read_config(&config_raw)?;
589 let config_text = String::from_utf8(config_raw).map_err(io::Error::other)?;
590 let state_raw = read_optional(&dir.join(STATE))?;
591 let state_digest = state_raw.as_deref().map(Digest::of);
592 let state = state_raw.map(|raw| from_toml(&raw, &label(STATE))).transpose()?;
593 let lock = |name: &str| -> Result<Option<Lock>> {
594 let raw = read_optional(&dir.join(name))?;
595 raw.map(|raw| Lock::read(&raw, &label(name))).transpose()
596 };
597 Ok(Self {
598 root: root.to_owned(),
599 config,
600 config_text,
601 lock: lock(LOCK)?.unwrap_or_default(),
602 pending: lock(&format!("{CONFLICTS}/{DIR}/{PENDING}"))?,
603 state: state.unwrap_or_default(),
604 state_digest,
605 answers: read_toml(&dir, ANSWERS)?.unwrap_or_default(),
606 given: BTreeSet::new(),
607 rescaffold: BTreeSet::new(),
608 })
609 }
610}
611
612fn set_features(table: &mut dyn TableLike, features: &[FeatureName]) {
615 if features.is_empty() {
616 table.remove("features");
617 return;
618 }
619 let mut array: Array = features.iter().map(FeatureName::as_str).collect();
620 array.fmt();
621 match table.get_mut("features").and_then(Item::as_value_mut) {
622 Some(value) => {
623 let decor = value.decor().clone();
624 *value = Value::Array(array);
625 *value.decor_mut() = decor;
626 },
627 None => {
628 table.insert("features", toml_edit::value(array));
629 },
630 }
631}
632
633fn read_config(raw: &[u8]) -> Result<Config> {
636 let file = label(CONFIG);
637 let table: toml::Table = from_toml(raw, &file)?;
638 let located = |layer: &toml::Value| {
639 layer
640 .as_table()
641 .is_some_and(|layer| layer.contains_key("git") || layer.contains_key("path"))
642 };
643 let layers = table.get("layers").and_then(toml::Value::as_array);
644 if layers.is_some_and(|layers| layers.iter().any(located)) {
645 return Err(TargetError::OldConfig.into());
646 }
647 let config: Config = from_toml(raw, &file)?;
648 let mut named = BTreeSet::new();
649 if let Some(layer) = config.layers.iter().find(|layer| !named.insert(&layer.profile.profile)) {
650 return Err(TargetError::LayerTwice { layer: layer.profile.profile.clone() }.into());
651 }
652 Ok(config)
653}
654
655pub(crate) fn label(name: &str) -> String {
657 format!("{DIR}/{name}")
658}
659
660fn enclosing(dir: &Utf8Path) -> Option<&Utf8Path> {
662 dir.ancestors().find(|d| d.join(DIR).join(CONFIG).is_file())
663}
664
665#[derive(Debug, Default, Serialize, Deserialize)]
667#[serde(deny_unknown_fields)]
668pub(crate) struct Lock {
669 version: V2,
671 #[serde(default, skip_serializing_if = "Vec::is_empty")]
673 profiles: Vec<Locked>,
674}
675
676#[derive(Debug, Serialize, Deserialize)]
678#[serde(deny_unknown_fields)]
679pub(crate) struct Locked {
680 pub name: ProfileName,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub rev: Option<Oid>,
685 pub digest: Digest,
687 pub source: Source,
689 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
691 pub features: BTreeMap<FeatureName, Vec<String>>,
692}
693
694impl Locked {
695 pub(crate) fn features(
697 features: &BTreeMap<FeatureName, BTreeSet<Enabler>>,
698 ) -> BTreeMap<FeatureName, Vec<String>> {
699 features
700 .iter()
701 .map(|(feature, by)| (feature.clone(), by.iter().map(ToString::to_string).collect()))
702 .collect()
703 }
704}
705
706impl Lock {
707 pub(crate) const fn new(profiles: Vec<Locked>) -> Self {
709 Self { version: V2, profiles }
710 }
711
712 fn read(raw: &[u8], file: &str) -> Result<Self> {
714 let table: toml::Table = from_toml(raw, file)?;
715 if table.get("version").and_then(toml::Value::as_integer) == Some(1) {
716 return Ok(Self::default());
717 }
718 from_toml(raw, file)
719 }
720}
721
722#[derive(Debug, Default, Serialize, Deserialize)]
724#[serde(deny_unknown_fields)]
725pub(crate) struct State {
726 version: V2,
728 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
730 scaffolds: BTreeMap<ScaffoldId, Scaffold>,
731 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
733 files: BTreeMap<RelPath, FileRecord>,
734 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
736 parts: BTreeMap<RelPath, BTreeMap<String, PartRecord>>,
737}
738
739#[derive(Clone, Copy, Debug)]
741pub(crate) struct Record {
742 pub scope: Scope,
744 pub fingerprint: Fingerprint,
746 pub policy: Policy,
748}
749
750#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
752#[serde(deny_unknown_fields)]
753struct FileRecord {
754 exact: Digest,
756 canonical: Digest,
758 #[serde(default, skip_serializing_if = "Policy::is_owned")]
760 policy: Policy,
761}
762
763#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
765#[serde(deny_unknown_fields)]
766struct PartRecord {
767 scope: Scope,
769 exact: Digest,
771 canonical: Digest,
773 #[serde(default, skip_serializing_if = "Policy::is_owned")]
775 policy: Policy,
776}
777
778impl State {
779 pub(crate) fn new(
781 records: BTreeMap<Slot, Record>, scaffolds: BTreeMap<ScaffoldId, Scaffold>,
782 ) -> Self {
783 let mut state = Self { scaffolds, ..Self::default() };
784 for (Slot { path, part }, Record { scope, fingerprint, policy }) in records {
785 let Fingerprint { exact, canonical } = fingerprint;
786 match part {
787 None => {
788 state.files.insert(path, FileRecord { exact, canonical, policy });
789 },
790 Some(owner) => {
791 let record = PartRecord { scope, exact, canonical, policy };
792 state.parts.entry(path).or_default().insert(owner, record);
793 },
794 }
795 }
796 state
797 }
798
799 fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
801 let files = self.files.iter().map(|(path, record)| {
802 let FileRecord { exact, canonical, policy } = *record;
803 let fingerprint = Fingerprint { exact, canonical };
804 (Slot::whole(path.clone()), Record { scope: Scope::File, fingerprint, policy })
805 });
806 let parts = self.parts.iter().flat_map(|(path, parts)| {
807 parts.iter().map(|(owner, record)| {
808 let slot = Slot { path: path.clone(), part: Some(owner.clone()) };
809 let PartRecord { scope, exact, canonical, policy } = *record;
810 (slot, Record { scope, fingerprint: Fingerprint { exact, canonical }, policy })
811 })
812 });
813 files.chain(parts)
814 }
815
816 pub(crate) fn bases(&self) -> impl Iterator<Item = Digest> {
818 self.records().map(|(_, record)| record.fingerprint.exact)
819 }
820}
821
822#[derive(Clone, Copy, Debug, Default)]
824pub(crate) struct V1;
825
826impl Serialize for V1 {
827 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
828 serializer.serialize_u8(1)
829 }
830}
831
832impl<'de> Deserialize<'de> for V1 {
833 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
834 match u8::deserialize(deserializer)? {
835 1 => Ok(Self),
836 other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
837 }
838 }
839}
840
841#[derive(Clone, Copy, Debug, Default)]
843pub(crate) struct V2;
844
845impl Serialize for V2 {
846 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
847 serializer.serialize_u8(2)
848 }
849}
850
851impl<'de> Deserialize<'de> for V2 {
852 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
853 match u8::deserialize(deserializer)? {
854 1 | 2 => Ok(Self),
855 other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
856 }
857 }
858}
859
860pub(crate) fn from_toml<T: DeserializeOwned>(bytes: &[u8], file: &str) -> Result<T> {
862 let failed = |text: &str, span, message| ParseError {
863 file: file.into(),
864 text: text.into(),
865 span,
866 message,
867 };
868 let text = str::from_utf8(bytes).map_err(|e| failed("", None, e.to_string()))?;
869 Ok(toml::from_str(text).map_err(|e| failed(text, e.span(), e.message().to_owned()))?)
870}
871
872pub(crate) fn read_toml<T: DeserializeOwned>(dir: &Utf8Path, name: &str) -> Result<Option<T>> {
874 read_optional(&dir.join(name))?.map(|raw| from_toml(&raw, &label(name))).transpose()
875}
876
877pub(crate) fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>> {
879 match fs_err::read(path) {
880 Ok(bytes) => Ok(Some(bytes)),
881 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
882 Err(e) => Err(e.into()),
883 }
884}
885
886#[cfg(test)]
887mod tests {
888 use super::{LayerSpec, Lock, State, Target};
889 use crate::errors::{Error, TargetError};
890 use crate::source::Source;
891
892 #[test]
893 fn a_new_target_keeps_its_skeleton_above_what_is_added() {
894 let dir = camino_tempfile::tempdir().expect("a directory");
895 let mut target = Target::open_or_new(dir.path()).expect("a new target");
896 target
897 .add_source("house".parse().expect("a name"), Source::Dir("../p".into()))
898 .expect("added");
899 target.add_layer(LayerSpec::new("house/base".parse().expect("a layer"))).expect("added");
900 let text = target.config_text();
901 assert!(text.starts_with("# devset applies"), "the skeleton leads: {text}");
902 assert!(
903 text.contains("\n\n[sources]\nhouse = { path = \"../p\" }\n"),
904 "a source, inline: {text}"
905 );
906 assert!(text.ends_with("[[layers]]\nprofile = \"house/base\"\n"), "then the layer: {text}");
907 let same = target.add_source("house".parse().expect("a name"), Source::Dir("../p".into()));
908 assert!(same.is_ok(), "naming a source again as it is changes nothing");
909 let other = target.add_source("house".parse().expect("a name"), Source::Dir("../q".into()));
910 assert!(matches!(other, Err(Error::Target(TargetError::SourceExists { .. }))), "{other:?}");
911 let twice = target.add_layer(LayerSpec::new("house/base".parse().expect("a layer")));
912 assert!(
913 matches!(twice, Err(Error::Target(TargetError::DuplicateLayer { .. }))),
914 "{twice:?}"
915 );
916 let unknown = target.add_layer(LayerSpec::new("nope/other".parse().expect("a layer")));
917 assert!(
918 matches!(unknown, Err(Error::Target(TargetError::NoSuchSource { .. }))),
919 "{unknown:?}"
920 );
921 }
922
923 #[test]
924 fn devset_0_1_records() {
925 let dir = camino_tempfile::tempdir().expect("a directory");
926 let devset = dir.path().join(".devset");
927 fs_err::create_dir_all(&devset).expect("a directory");
928 fs_err::write(devset.join("config.toml"), "[[layers]]\npath = \"../p\"\n")
929 .expect("written");
930 let old = Target::find(dir.path()).expect_err("an old config");
931 assert!(matches!(old, Error::Target(TargetError::OldConfig)), "refused: {old}");
932 let lock =
933 Lock::read(b"version = 1\n[[layers]]\ndigest = \"x\"\n", "lock.toml").expect("read");
934 assert!(lock.profiles.is_empty(), "an old lock pins nothing");
935 let state: State = toml::from_str(
936 "version = 1\n[files.\"a\"]\nexact = \"0000000000000000000000000000000000000000000000000000000000000000\"\ncanonical = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
937 )
938 .expect("an old state reads");
939 assert_eq!(state.records().count(), 1, "with its records");
940 let written = toml::to_string(&state).expect("written");
941 assert!(written.starts_with("version = 2\n"), "as version 2: {written}");
942 }
943}