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::{GitRef, 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#:schema https://atomix-labs.github.io/devset/schema/config.json
93# devset applies the profiles below to this directory, and keeps them up to date.
94# The manual: https://atomix-labs.github.io/devset/
95#
96# Where profiles come from, each named once: a git repository at a ref, or a directory.
97#
98# [sources]
99# atxp = { git = \"https://github.com/atomix-labs/atxp\", tag = \"v0.9.1\" }
100#
101# The profiles to apply, in order, by source and name, with the features to turn on.
102#
103# [[layers]]
104# profile = \"atxp/rust\"
105# features = [\"docs\"]
106";
107
108#[derive(Debug, Default, Deserialize, JsonSchema)]
111#[serde(deny_unknown_fields)]
112pub struct Config {
113 #[serde(default)]
115 pub sources: BTreeMap<SourceName, Source>,
116 #[serde(default)]
118 pub layers: Vec<LayerSpec>,
119 #[serde(default)]
121 pub merge: MergeSpec,
122 #[serde(default)]
124 pub files: BTreeMap<RelPath, Override>,
125}
126
127#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
129#[serde(deny_unknown_fields, rename_all = "kebab-case")]
130pub struct LayerSpec {
131 pub profile: ProfileRef,
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub features: Vec<FeatureName>,
136 #[serde(default = "yes", skip_serializing_if = "is_yes")]
138 pub default_features: bool,
139}
140
141impl LayerSpec {
142 #[must_use]
144 pub const fn new(profile: ProfileRef) -> Self {
145 Self { profile, features: Vec::new(), default_features: true }
146 }
147}
148
149const fn yes() -> bool {
151 true
152}
153
154#[expect(
156 clippy::trivially_copy_pass_by_ref,
157 reason = "serde's `skip_serializing_if` passes a reference"
158)]
159const fn is_yes(flag: &bool) -> bool {
160 *flag
161}
162
163#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
165#[serde(deny_unknown_fields)]
166pub struct Override {
167 #[serde(default)]
169 pub from: Option<ProfileName>,
170 #[serde(default)]
172 pub policy: Option<Policy>,
173 #[serde(default)]
175 pub validate: Option<Format>,
176}
177
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181pub struct Scaffold {
182 pub decision: Scaffolded,
184 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
187 pub files: BTreeMap<RelPath, RelPath>,
188}
189
190#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Display)]
192#[serde(rename_all = "lowercase")]
193#[display(rename_all = "lowercase")]
194pub enum Scaffolded {
195 Written,
197 Found,
199}
200
201impl Target {
202 pub fn find(dir: &Utf8Path) -> Result<Self> {
209 let root = enclosing(dir).ok_or_else(|| TargetError::NotFound { dir: dir.to_owned() })?;
210 Self::load(root)
211 }
212
213 pub fn open_or_new(root: &Utf8Path) -> Result<Self> {
221 match enclosing(root) {
222 Some(found) if found == root => Self::load(root),
223 Some(outer) => {
224 Err(TargetError::Nested { dir: root.to_owned(), root: outer.to_owned() }.into())
225 },
226 None => Ok(Self {
227 root: root.to_owned(),
228 config: Config::default(),
229 config_text: SKELETON.to_owned(),
230 lock: Lock::default(),
231 pending: None,
232 state: State::default(),
233 answers: BTreeMap::new(),
234 given: BTreeSet::new(),
235 rescaffold: BTreeSet::new(),
236 state_digest: None,
237 }),
238 }
239 }
240
241 #[must_use]
243 pub fn root(&self) -> &Utf8Path {
244 &self.root
245 }
246
247 #[must_use]
249 pub const fn config(&self) -> &Config {
250 &self.config
251 }
252
253 #[must_use]
256 pub fn exists(&self) -> bool {
257 self.dir().join(CONFIG).is_file()
258 }
259
260 pub fn add_source(&mut self, name: SourceName, source: Source) -> Result<()> {
268 match self.config.sources.get(&name) {
269 Some(named) if *named == source => return Ok(()),
270 Some(named) => {
271 return Err(TargetError::SourceExists { name, location: named.to_string() }.into());
272 },
273 None => {},
274 }
275 let spec = ser::to_document(&SourceSpec::from(source)).map_err(io::Error::other)?;
276 let inline = spec.into_table().into_inline_table();
277 self.edit("`sources` must be a table", |doc| {
278 let sources = doc.entry("sources").or_insert_with(|| Item::Table(Table::new()));
279 let Some(sources) = sources.as_table_like_mut() else { return false };
280 sources.insert(name.as_str(), Item::Value(Value::InlineTable(inline)));
281 true
282 })
283 }
284
285 pub fn repin(&mut self, name: &SourceName, at: GitRef) -> Result<()> {
293 match self.config.sources.get(name) {
294 Some(Source::Git { .. }) => {},
295 Some(Source::Dir(_)) => return Err(TargetError::NotGit { name: name.clone() }.into()),
296 None => {
297 let sources = self.config.sources.keys().cloned().collect();
298 let (name, layers) = (name.clone(), Vec::new());
299 return Err(TargetError::NoSuchSource { name, sources, layers }.into());
300 },
301 }
302 let written = match at {
303 GitRef::Head => None,
304 GitRef::Tag(tag) => Some(("tag", tag)),
305 GitRef::Branch(branch) => Some(("branch", branch)),
306 GitRef::Rev(rev) => Some(("rev", rev.into())),
307 };
308 self.edit("`sources` must be a table of sources", |doc| {
309 let Some(entry) = doc
310 .get_mut("sources")
311 .and_then(Item::as_table_like_mut)
312 .and_then(|sources| sources.get_mut(name.as_str()))
313 else {
314 return false;
315 };
316 let Some(table) = entry.as_table_like_mut() else { return false };
317 let kept = written.as_ref().map(|(key, _)| *key);
318 for key in ["tag", "branch", "rev"] {
319 if Some(key) != kept {
320 table.remove(key);
321 }
322 }
323 if let Some((key, value)) = written {
324 match table.get_mut(key).and_then(Item::as_value_mut) {
326 Some(old) => {
327 let decor = old.decor().clone();
328 *old = Value::from(value);
329 *old.decor_mut() = decor;
330 },
331 None => {
332 table.insert(key, toml_edit::value(value));
333 },
334 }
335 }
336 if let Some(inline) = entry.as_inline_table_mut() {
337 inline.fmt();
338 }
339 true
340 })
341 }
342
343 pub fn add_layer(&mut self, layer: LayerSpec) -> Result<()> {
350 if self.layer(&layer.profile.profile).is_some() {
351 return Err(TargetError::DuplicateLayer { layer: layer.profile.profile }.into());
352 }
353 if !self.config.sources.contains_key(&layer.profile.source) {
354 let sources = self.config.sources.keys().cloned().collect();
355 let (name, layers) = (layer.profile.source.clone(), vec![layer.profile]);
356 return Err(TargetError::NoSuchSource { name, sources, layers }.into());
357 }
358 let table = ser::to_document(&layer).map_err(io::Error::other)?.into_table();
359 self.edit("`layers` must be a list of tables", |doc| {
360 match doc.entry("layers").or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())) {
361 Item::ArrayOfTables(layers) => layers.push(table),
362 Item::Value(Value::Array(layers)) => layers.push(table.into_inline_table()),
363 Item::None | Item::Value(_) | Item::Table(_) => return false,
364 }
365 true
366 })
367 }
368
369 #[must_use]
371 pub fn layer(&self, name: &ProfileName) -> Option<&LayerSpec> {
372 self.config.layers.iter().find(|layer| layer.profile.profile == *name)
373 }
374
375 pub fn remove_layer(&mut self, name: &ProfileName) -> Result<()> {
384 let Some(index) = self.config.layers.iter().position(|l| l.profile.profile == *name) else {
385 return Ok(());
386 };
387 self.edit("`layers` must be a list of tables", |doc| {
388 match doc.get_mut("layers") {
389 Some(Item::ArrayOfTables(layers)) if index < layers.len() => {
390 layers.remove(index);
391 },
392 Some(Item::Value(Value::Array(layers))) if index < layers.len() => {
393 layers.remove(index);
394 },
395 Some(_) | None => return false,
396 }
397 if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
398 let picks =
399 |item: &Item| item.get("from").and_then(Item::as_str) == Some(name.as_str());
400 let picked: Vec<String> = files
401 .iter()
402 .filter(|(_, item)| picks(item))
403 .map(|(key, _)| key.to_owned())
404 .collect();
405 for key in picked {
406 files.remove(&key);
407 }
408 }
409 true
410 })
411 }
412
413 pub fn turn_on(
422 &mut self, name: &ProfileName, features: &[FeatureName], defaults: Option<bool>,
423 ) -> Result<(Vec<FeatureName>, Option<bool>)> {
424 let (index, layer) = self.find_layer(name)?;
425 let mut listed = layer.features.clone();
426 let added: Vec<FeatureName> =
427 features.iter().filter(|feature| !listed.contains(feature)).cloned().collect();
428 listed.extend(added.iter().cloned());
429 let defaults = defaults.filter(|&on| on != layer.default_features);
430 if !added.is_empty() || defaults.is_some() {
431 self.edit_layer(index, |table| {
432 set_features(table, &listed);
433 match defaults {
434 Some(true) => {
435 table.remove("default-features");
436 },
437 Some(false) => {
438 table.insert("default-features", toml_edit::value(false));
439 },
440 None => {},
441 }
442 })?;
443 }
444 Ok((added, defaults))
445 }
446
447 pub fn turn_off(&mut self, name: &ProfileName, features: &[FeatureName]) -> Result<()> {
455 let (index, layer) = self.find_layer(name)?;
456 if let Some(feature) = features.iter().find(|feature| !layer.features.contains(feature)) {
457 let (feature, listed) = (feature.clone(), layer.features.clone());
458 return Err(TargetError::NotListed { layer: name.clone(), feature, listed }.into());
459 }
460 let listed: Vec<FeatureName> =
461 layer.features.into_iter().filter(|feature| !features.contains(feature)).collect();
462 self.edit_layer(index, |table| set_features(table, &listed))
463 }
464
465 fn find_layer(&self, name: &ProfileName) -> Result<(usize, LayerSpec)> {
467 let layers = &self.config.layers;
468 let found = layers.iter().enumerate().find(|(_, layer)| layer.profile.profile == *name);
469 found.map(|(index, layer)| (index, layer.clone())).ok_or_else(|| {
470 let names = layers.iter().map(|layer| layer.profile.profile.to_string()).collect();
471 TargetError::NoSuchLayer { name: name.to_string(), names }.into()
472 })
473 }
474
475 fn edit_layer(&mut self, index: usize, edit: impl FnOnce(&mut dyn TableLike)) -> Result<()> {
477 self.edit("`layers` must be a list of tables", |doc| {
478 let table: &mut dyn TableLike = match doc.get_mut("layers") {
479 Some(Item::ArrayOfTables(layers)) => match layers.get_mut(index) {
480 Some(layer) => layer,
481 None => return false,
482 },
483 Some(Item::Value(Value::Array(layers))) => {
484 match layers.get_mut(index).and_then(Value::as_inline_table_mut) {
485 Some(layer) => layer,
486 None => return false,
487 }
488 },
489 Some(_) | None => return false,
490 };
491 edit(table);
492 true
493 })
494 }
495
496 pub fn remove_override(&mut self, path: &RelPath) -> Result<()> {
501 self.edit("`files` must be a table", |doc| {
502 if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
503 files.remove(path.as_str());
504 }
505 true
506 })
507 }
508
509 fn edit(&mut self, shape: &str, edit: impl FnOnce(&mut DocumentMut) -> bool) -> Result<()> {
514 let file = label(CONFIG);
515 let failed = |text: &str, message: String| ParseError {
516 file: file.clone(),
517 text: text.to_owned(),
518 span: None,
519 message,
520 };
521 let mut doc: DocumentMut = self
522 .config_text
523 .parse()
524 .map_err(|e: TomlError| failed(&self.config_text, e.to_string()))?;
525 let lead = doc.is_empty().then(|| doc.trailing().as_str().map(str::to_owned)).flatten();
526 if !edit(&mut doc) {
527 return Err(failed(&self.config_text, shape.to_owned()).into());
528 }
529 if let Some(lead) = lead.filter(|lead| !lead.trim().is_empty()) {
530 doc.set_trailing("");
531 if let Some((_, first)) = doc.iter_mut().next() {
532 let prefix = format!("{}\n\n", lead.trim_end());
533 match first {
534 Item::Table(table) => table.decor_mut().set_prefix(prefix),
535 Item::ArrayOfTables(tables) => {
536 if let Some(table) = tables.get_mut(0) {
537 table.decor_mut().set_prefix(prefix);
538 }
539 },
540 Item::Value(_) | Item::None => {},
541 }
542 }
543 }
544 let text = doc.to_string();
545 self.config = from_toml(text.as_bytes(), &file)?;
546 self.config_text = text;
547 Ok(())
548 }
549
550 pub fn answer(&mut self, name: VarName, value: String) {
552 self.given.insert(name.clone());
553 self.answers.insert(name, value);
554 }
555
556 #[must_use]
558 pub const fn answers(&self) -> &BTreeMap<VarName, String> {
559 &self.answers
560 }
561
562 pub fn rescaffold(&mut self, scaffold: ScaffoldId) {
565 self.rescaffold.insert(scaffold);
566 }
567
568 pub(crate) const fn rescaffolds(&self) -> &BTreeSet<ScaffoldId> {
570 &self.rescaffold
571 }
572
573 pub(crate) const fn scaffolds(&self) -> &BTreeMap<ScaffoldId, Scaffold> {
575 &self.state.scaffolds
576 }
577
578 pub(crate) fn given(&self) -> impl Iterator<Item = &VarName> {
580 self.given.iter()
581 }
582
583 pub(crate) fn dir(&self) -> Utf8PathBuf {
585 self.root.join(DIR)
586 }
587
588 pub(crate) fn config_text(&self) -> &str {
590 &self.config_text
591 }
592
593 fn lock(&self) -> &Lock {
595 self.pending.as_ref().unwrap_or(&self.lock)
596 }
597
598 #[must_use]
602 pub fn pinned(&self, source: &Source) -> Option<&Oid> {
603 let locked = self.lock().profiles.iter();
604 locked.filter(|locked| locked.source == *source).find_map(|locked| locked.rev.as_ref())
605 }
606
607 pub(crate) fn locked(&self, source: &Source, name: &ProfileName) -> Option<Digest> {
609 let lock = self.lock();
610 let locked = lock.profiles.iter().find(|l| l.source == *source && l.name == *name);
611 locked.map(|locked| locked.digest)
612 }
613
614 pub(crate) fn sidecar(&self, path: &RelPath) -> Utf8PathBuf {
616 path.under(&self.dir().join(CONFLICTS))
617 }
618
619 pub(crate) fn unfinished(&self) -> Utf8PathBuf {
624 self.dir().join(CONFLICTS).join(DIR)
625 }
626
627 pub(crate) fn base(&self, record: &Fingerprint) -> Result<Option<Vec<u8>>> {
629 let blob = read_optional(&self.dir().join(BASE).join(record.exact.to_string()))?;
630 Ok(blob.filter(|bytes| Digest::of(bytes) == record.exact))
631 }
632
633 pub(crate) fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
635 self.state.records()
636 }
637
638 pub(crate) const fn state_digest(&self) -> Option<Digest> {
640 self.state_digest
641 }
642
643 fn load(root: &Utf8Path) -> Result<Self> {
645 let dir = root.join(DIR);
646 let config_raw = fs_err::read(dir.join(CONFIG))?;
647 let config = read_config(&config_raw)?;
648 let config_text = String::from_utf8(config_raw).map_err(io::Error::other)?;
649 let state_raw = read_optional(&dir.join(STATE))?;
650 let state_digest = state_raw.as_deref().map(Digest::of);
651 let state = state_raw.map(|raw| from_toml(&raw, &label(STATE))).transpose()?;
652 let lock = |name: &str| -> Result<Option<Lock>> {
653 let raw = read_optional(&dir.join(name))?;
654 raw.map(|raw| Lock::read(&raw, &label(name))).transpose()
655 };
656 Ok(Self {
657 root: root.to_owned(),
658 config,
659 config_text,
660 lock: lock(LOCK)?.unwrap_or_default(),
661 pending: lock(&format!("{CONFLICTS}/{DIR}/{PENDING}"))?,
662 state: state.unwrap_or_default(),
663 state_digest,
664 answers: read_toml(&dir, ANSWERS)?.unwrap_or_default(),
665 given: BTreeSet::new(),
666 rescaffold: BTreeSet::new(),
667 })
668 }
669}
670
671fn set_features(table: &mut dyn TableLike, features: &[FeatureName]) {
674 if features.is_empty() {
675 table.remove("features");
676 return;
677 }
678 let mut array: Array = features.iter().map(FeatureName::as_str).collect();
679 array.fmt();
680 match table.get_mut("features").and_then(Item::as_value_mut) {
681 Some(value) => {
682 let decor = value.decor().clone();
683 *value = Value::Array(array);
684 *value.decor_mut() = decor;
685 },
686 None => {
687 table.insert("features", toml_edit::value(array));
688 },
689 }
690}
691
692fn read_config(raw: &[u8]) -> Result<Config> {
695 let file = label(CONFIG);
696 let table: toml::Table = from_toml(raw, &file)?;
697 let located = |layer: &toml::Value| {
698 layer
699 .as_table()
700 .is_some_and(|layer| layer.contains_key("git") || layer.contains_key("path"))
701 };
702 let layers = table.get("layers").and_then(toml::Value::as_array);
703 if layers.is_some_and(|layers| layers.iter().any(located)) {
704 return Err(TargetError::OldConfig.into());
705 }
706 let config: Config = from_toml(raw, &file)?;
707 let mut named = BTreeSet::new();
708 if let Some(layer) = config.layers.iter().find(|layer| !named.insert(&layer.profile.profile)) {
709 return Err(TargetError::LayerTwice { layer: layer.profile.profile.clone() }.into());
710 }
711 Ok(config)
712}
713
714pub(crate) fn label(name: &str) -> String {
716 format!("{DIR}/{name}")
717}
718
719fn enclosing(dir: &Utf8Path) -> Option<&Utf8Path> {
721 dir.ancestors().find(|d| d.join(DIR).join(CONFIG).is_file())
722}
723
724#[derive(Debug, Default, Serialize, Deserialize)]
726#[serde(deny_unknown_fields)]
727pub(crate) struct Lock {
728 version: V2,
730 #[serde(default, skip_serializing_if = "Vec::is_empty")]
732 profiles: Vec<Locked>,
733}
734
735#[derive(Debug, Serialize, Deserialize)]
737#[serde(deny_unknown_fields)]
738pub(crate) struct Locked {
739 pub name: ProfileName,
741 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub rev: Option<Oid>,
744 pub digest: Digest,
746 pub source: Source,
748 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
750 pub features: BTreeMap<FeatureName, Vec<String>>,
751}
752
753impl Locked {
754 pub(crate) fn features(
756 features: &BTreeMap<FeatureName, BTreeSet<Enabler>>,
757 ) -> BTreeMap<FeatureName, Vec<String>> {
758 features
759 .iter()
760 .map(|(feature, by)| (feature.clone(), by.iter().map(ToString::to_string).collect()))
761 .collect()
762 }
763}
764
765impl Lock {
766 pub(crate) const fn new(profiles: Vec<Locked>) -> Self {
768 Self { version: V2, profiles }
769 }
770
771 fn read(raw: &[u8], file: &str) -> Result<Self> {
773 let table: toml::Table = from_toml(raw, file)?;
774 if table.get("version").and_then(toml::Value::as_integer) == Some(1) {
775 return Ok(Self::default());
776 }
777 from_toml(raw, file)
778 }
779}
780
781#[derive(Debug, Default, Serialize, Deserialize)]
783#[serde(deny_unknown_fields)]
784pub(crate) struct State {
785 version: V2,
787 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
789 scaffolds: BTreeMap<ScaffoldId, Scaffold>,
790 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
792 files: BTreeMap<RelPath, FileRecord>,
793 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
795 parts: BTreeMap<RelPath, BTreeMap<String, PartRecord>>,
796}
797
798#[derive(Clone, Copy, Debug)]
800pub(crate) struct Record {
801 pub scope: Scope,
803 pub fingerprint: Fingerprint,
805 pub policy: Policy,
807}
808
809#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
811#[serde(deny_unknown_fields)]
812struct FileRecord {
813 exact: Digest,
815 canonical: Digest,
817 #[serde(default, skip_serializing_if = "Policy::is_owned")]
819 policy: Policy,
820}
821
822#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
824#[serde(deny_unknown_fields)]
825struct PartRecord {
826 scope: Scope,
828 exact: Digest,
830 canonical: Digest,
832 #[serde(default, skip_serializing_if = "Policy::is_owned")]
834 policy: Policy,
835}
836
837impl State {
838 pub(crate) fn new(
840 records: BTreeMap<Slot, Record>, scaffolds: BTreeMap<ScaffoldId, Scaffold>,
841 ) -> Self {
842 let mut state = Self { scaffolds, ..Self::default() };
843 for (Slot { path, part }, Record { scope, fingerprint, policy }) in records {
844 let Fingerprint { exact, canonical } = fingerprint;
845 match part {
846 None => {
847 state.files.insert(path, FileRecord { exact, canonical, policy });
848 },
849 Some(owner) => {
850 let record = PartRecord { scope, exact, canonical, policy };
851 state.parts.entry(path).or_default().insert(owner, record);
852 },
853 }
854 }
855 state
856 }
857
858 fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
860 let files = self.files.iter().map(|(path, record)| {
861 let FileRecord { exact, canonical, policy } = *record;
862 let fingerprint = Fingerprint { exact, canonical };
863 (Slot::whole(path.clone()), Record { scope: Scope::File, fingerprint, policy })
864 });
865 let parts = self.parts.iter().flat_map(|(path, parts)| {
866 parts.iter().map(|(owner, record)| {
867 let slot = Slot { path: path.clone(), part: Some(owner.clone()) };
868 let PartRecord { scope, exact, canonical, policy } = *record;
869 (slot, Record { scope, fingerprint: Fingerprint { exact, canonical }, policy })
870 })
871 });
872 files.chain(parts)
873 }
874
875 pub(crate) fn bases(&self) -> impl Iterator<Item = Digest> {
877 self.records().map(|(_, record)| record.fingerprint.exact)
878 }
879}
880
881#[derive(Clone, Copy, Debug, Default)]
883pub(crate) struct V1;
884
885impl Serialize for V1 {
886 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
887 serializer.serialize_u8(1)
888 }
889}
890
891impl<'de> Deserialize<'de> for V1 {
892 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
893 match u8::deserialize(deserializer)? {
894 1 => Ok(Self),
895 other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
896 }
897 }
898}
899
900#[derive(Clone, Copy, Debug, Default)]
902pub(crate) struct V2;
903
904impl Serialize for V2 {
905 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
906 serializer.serialize_u8(2)
907 }
908}
909
910impl<'de> Deserialize<'de> for V2 {
911 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
912 match u8::deserialize(deserializer)? {
913 1 | 2 => Ok(Self),
914 other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
915 }
916 }
917}
918
919pub(crate) fn from_toml<T: DeserializeOwned>(bytes: &[u8], file: &str) -> Result<T> {
921 let failed = |text: &str, span, message| ParseError {
922 file: file.into(),
923 text: text.into(),
924 span,
925 message,
926 };
927 let text = str::from_utf8(bytes).map_err(|e| failed("", None, e.to_string()))?;
928 Ok(toml::from_str(text).map_err(|e| failed(text, e.span(), e.message().to_owned()))?)
929}
930
931pub(crate) fn read_toml<T: DeserializeOwned>(dir: &Utf8Path, name: &str) -> Result<Option<T>> {
933 read_optional(&dir.join(name))?.map(|raw| from_toml(&raw, &label(name))).transpose()
934}
935
936pub(crate) fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>> {
938 match fs_err::read(path) {
939 Ok(bytes) => Ok(Some(bytes)),
940 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
941 Err(e) => Err(e.into()),
942 }
943}
944
945#[cfg(test)]
946mod tests {
947 use super::{LayerSpec, Lock, State, Target};
948 use crate::errors::{Error, TargetError};
949 use crate::name::SourceName;
950 use crate::source::{GitRef, Source, SourceSpec};
951
952 #[test]
953 fn a_source_moves_to_another_ref_in_place() {
954 let dir = camino_tempfile::tempdir().expect("a directory");
955 let mut target = Target::open_or_new(dir.path()).expect("a new target");
956 let spec = SourceSpec {
957 git: Some("https://example.com/p".into()),
958 tag: Some("v1.0.0".into()),
959 path: Some("lint".into()),
960 ..SourceSpec::default()
961 };
962 let name: SourceName = "p".parse().expect("a name");
963 target.add_source(name.clone(), Source::try_from(spec).expect("a source")).expect("added");
964 target.repin(&name, GitRef::Tag("v1.1.0".into())).expect("moved");
965 let text = target.config_text();
966 let moved = "p = { git = \"https://example.com/p\", tag = \"v1.1.0\", path = \"lint\" }";
967 assert!(text.contains(moved), "a tag moves in its place: {text}");
968 target.repin(&name, GitRef::Branch("main".into())).expect("moved");
969 let text = target.config_text();
970 let moved = "p = { git = \"https://example.com/p\", path = \"lint\", branch = \"main\" }";
971 assert!(text.contains(moved), "another kind of ref replaces it: {text}");
972 let local: SourceName = "d".parse().expect("a name");
973 target.add_source(local.clone(), Source::Dir("../d".into())).expect("added");
974 let refused = target.repin(&local, GitRef::Tag("v1".into()));
975 assert!(matches!(refused, Err(Error::Target(TargetError::NotGit { .. }))), "{refused:?}");
976 }
977
978 #[test]
979 fn a_source_written_as_a_table_keeps_its_layout() {
980 let dir = camino_tempfile::tempdir().expect("a directory");
981 let config =
982 "[sources.p]\ngit = \"https://example.com/p\"\ntag = \"v1.0.0\" # the release\n";
983 fs_err::create_dir_all(dir.path().join(".devset")).expect("a directory");
984 fs_err::write(dir.path().join(".devset/config.toml"), config).expect("written");
985 let mut target = Target::find(dir.path()).expect("the target");
986 target.repin(&"p".parse().expect("a name"), GitRef::Tag("v1.1.0".into())).expect("moved");
987 let want =
988 "[sources.p]\ngit = \"https://example.com/p\"\ntag = \"v1.1.0\" # the release\n";
989 assert_eq!(target.config_text(), want, "only the value changes");
990 }
991
992 #[test]
993 fn a_new_target_keeps_its_skeleton_above_what_is_added() {
994 let dir = camino_tempfile::tempdir().expect("a directory");
995 let mut target = Target::open_or_new(dir.path()).expect("a new target");
996 target
997 .add_source("house".parse().expect("a name"), Source::Dir("../p".into()))
998 .expect("added");
999 target.add_layer(LayerSpec::new("house/base".parse().expect("a layer"))).expect("added");
1000 let text = target.config_text();
1001 let lead =
1002 "#:schema https://atomix-labs.github.io/devset/schema/config.json\n# devset applies";
1003 assert!(text.starts_with(lead), "the skeleton leads, naming its schema: {text}");
1004 assert!(
1005 text.contains("\n\n[sources]\nhouse = { path = \"../p\" }\n"),
1006 "a source, inline: {text}"
1007 );
1008 assert!(text.ends_with("[[layers]]\nprofile = \"house/base\"\n"), "then the layer: {text}");
1009 let same = target.add_source("house".parse().expect("a name"), Source::Dir("../p".into()));
1010 assert!(same.is_ok(), "naming a source again as it is changes nothing");
1011 let other = target.add_source("house".parse().expect("a name"), Source::Dir("../q".into()));
1012 assert!(matches!(other, Err(Error::Target(TargetError::SourceExists { .. }))), "{other:?}");
1013 let twice = target.add_layer(LayerSpec::new("house/base".parse().expect("a layer")));
1014 assert!(
1015 matches!(twice, Err(Error::Target(TargetError::DuplicateLayer { .. }))),
1016 "{twice:?}"
1017 );
1018 let unknown = target.add_layer(LayerSpec::new("nope/other".parse().expect("a layer")));
1019 assert!(
1020 matches!(unknown, Err(Error::Target(TargetError::NoSuchSource { .. }))),
1021 "{unknown:?}"
1022 );
1023 }
1024
1025 #[test]
1026 fn devset_0_1_records() {
1027 let dir = camino_tempfile::tempdir().expect("a directory");
1028 let devset = dir.path().join(".devset");
1029 fs_err::create_dir_all(&devset).expect("a directory");
1030 fs_err::write(devset.join("config.toml"), "[[layers]]\npath = \"../p\"\n")
1031 .expect("written");
1032 let old = Target::find(dir.path()).expect_err("an old config");
1033 assert!(matches!(old, Error::Target(TargetError::OldConfig)), "refused: {old}");
1034 let lock =
1035 Lock::read(b"version = 1\n[[layers]]\ndigest = \"x\"\n", "lock.toml").expect("read");
1036 assert!(lock.profiles.is_empty(), "an old lock pins nothing");
1037 let state: State = toml::from_str(
1038 "version = 1\n[files.\"a\"]\nexact = \"0000000000000000000000000000000000000000000000000000000000000000\"\ncanonical = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
1039 )
1040 .expect("an old state reads");
1041 assert_eq!(state.records().count(), 1, "with its records");
1042 let written = toml::to_string(&state).expect("written");
1043 assert!(written.starts_with("version = 2\n"), "as version 2: {written}");
1044 }
1045}