use alloc::collections::{BTreeMap, BTreeSet};
use core::str;
use std::io;
use camino::{Utf8Path, Utf8PathBuf};
use derive_more::Display;
use schemars::JsonSchema;
use serde::de::{self, DeserializeOwned};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use toml_edit::{Array, ArrayOfTables, DocumentMut, Item, Table, TableLike, TomlError, Value, ser};
use crate::digest::{Digest, Fingerprint};
use crate::errors::{ParseError, Result, TargetError};
use crate::graph::Enabler;
use crate::name::{FeatureName, ProfileName, ProfileRef, ScaffoldId, SourceName};
use crate::part::{Scope, Slot};
use crate::path::RelPath;
use crate::profile::{Format, MergeSpec, Policy};
use crate::source::{GitRef, Oid, Source, SourceSpec};
use crate::vars::VarName;
pub(crate) const DIR: &str = ".devset";
pub(crate) const CONFIG: &str = "config.toml";
pub(crate) const LOCK: &str = "lock.toml";
pub(crate) const STATE: &str = "state.toml";
pub(crate) const CONFLICTS: &str = "conflicts";
pub(crate) const PENDING: &str = "pending.toml";
pub(crate) const UNDO: &str = "undo.toml";
pub(crate) const UNDO_BLOBS: &str = "undo";
pub(crate) const BASE: &str = "base";
pub(crate) const ANSWERS: &str = "answers.toml";
#[derive(Debug)]
pub struct Target {
root: Utf8PathBuf,
config: Config,
config_text: String,
lock: Lock,
pending: Option<Lock>,
state: State,
answers: BTreeMap<VarName, String>,
given: BTreeSet<VarName>,
rescaffold: BTreeSet<ScaffoldId>,
state_digest: Option<Digest>,
}
const SKELETON: &str = "\
#:schema https://atomix-labs.github.io/devset/schema/config.json
# devset applies the profiles below to this directory, and keeps them up to date.
# The manual: https://atomix-labs.github.io/devset/
#
# Where profiles come from, each named once: a git repository at a ref, or a directory.
#
# [sources]
# atxp = { git = \"https://github.com/atomix-labs/atxp\", tag = \"v0.9.1\" }
#
# The profiles to apply, in order, by source and name, with the features to turn on.
#
# [[layers]]
# profile = \"atxp/rust\"
# features = [\"docs\"]
";
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub sources: BTreeMap<SourceName, Source>,
#[serde(default)]
pub layers: Vec<LayerSpec>,
#[serde(default)]
pub merge: MergeSpec,
#[serde(default)]
pub files: BTreeMap<RelPath, Override>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields, rename_all = "kebab-case")]
pub struct LayerSpec {
pub profile: ProfileRef,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub features: Vec<FeatureName>,
#[serde(default = "yes", skip_serializing_if = "is_yes")]
pub default_features: bool,
}
impl LayerSpec {
#[must_use]
pub const fn new(profile: ProfileRef) -> Self {
Self { profile, features: Vec::new(), default_features: true }
}
}
const fn yes() -> bool {
true
}
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde's `skip_serializing_if` passes a reference"
)]
const fn is_yes(flag: &bool) -> bool {
*flag
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Override {
#[serde(default)]
pub from: Option<ProfileName>,
#[serde(default)]
pub policy: Option<Policy>,
#[serde(default)]
pub validate: Option<Format>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Scaffold {
pub decision: Scaffolded,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub files: BTreeMap<RelPath, RelPath>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Display)]
#[serde(rename_all = "lowercase")]
#[display(rename_all = "lowercase")]
pub enum Scaffolded {
Written,
Found,
}
impl Target {
pub fn find(dir: &Utf8Path) -> Result<Self> {
let root = enclosing(dir).ok_or_else(|| TargetError::NotFound { dir: dir.to_owned() })?;
Self::load(root)
}
pub fn open_or_new(root: &Utf8Path) -> Result<Self> {
match enclosing(root) {
Some(found) if found == root => Self::load(root),
Some(outer) => {
Err(TargetError::Nested { dir: root.to_owned(), root: outer.to_owned() }.into())
},
None => Ok(Self {
root: root.to_owned(),
config: Config::default(),
config_text: SKELETON.to_owned(),
lock: Lock::default(),
pending: None,
state: State::default(),
answers: BTreeMap::new(),
given: BTreeSet::new(),
rescaffold: BTreeSet::new(),
state_digest: None,
}),
}
}
#[must_use]
pub fn root(&self) -> &Utf8Path {
&self.root
}
#[must_use]
pub const fn config(&self) -> &Config {
&self.config
}
#[must_use]
pub fn exists(&self) -> bool {
self.dir().join(CONFIG).is_file()
}
pub fn add_source(&mut self, name: SourceName, source: Source) -> Result<()> {
match self.config.sources.get(&name) {
Some(named) if *named == source => return Ok(()),
Some(named) => {
return Err(TargetError::SourceExists { name, location: named.to_string() }.into());
},
None => {},
}
let spec = ser::to_document(&SourceSpec::from(source)).map_err(io::Error::other)?;
let inline = spec.into_table().into_inline_table();
self.edit("`sources` must be a table", |doc| {
let sources = doc.entry("sources").or_insert_with(|| Item::Table(Table::new()));
let Some(sources) = sources.as_table_like_mut() else { return false };
sources.insert(name.as_str(), Item::Value(Value::InlineTable(inline)));
true
})
}
pub fn repin(&mut self, name: &SourceName, at: GitRef) -> Result<()> {
match self.config.sources.get(name) {
Some(Source::Git { .. }) => {},
Some(Source::Dir(_)) => return Err(TargetError::NotGit { name: name.clone() }.into()),
None => {
let sources = self.config.sources.keys().cloned().collect();
let (name, layers) = (name.clone(), Vec::new());
return Err(TargetError::NoSuchSource { name, sources, layers }.into());
},
}
let written = match at {
GitRef::Head => None,
GitRef::Tag(tag) => Some(("tag", tag)),
GitRef::Branch(branch) => Some(("branch", branch)),
GitRef::Rev(rev) => Some(("rev", rev.into())),
};
self.edit("`sources` must be a table of sources", |doc| {
let Some(entry) = doc
.get_mut("sources")
.and_then(Item::as_table_like_mut)
.and_then(|sources| sources.get_mut(name.as_str()))
else {
return false;
};
let Some(table) = entry.as_table_like_mut() else { return false };
let kept = written.as_ref().map(|(key, _)| *key);
for key in ["tag", "branch", "rev"] {
if Some(key) != kept {
table.remove(key);
}
}
if let Some((key, value)) = written {
match table.get_mut(key).and_then(Item::as_value_mut) {
Some(old) => {
let decor = old.decor().clone();
*old = Value::from(value);
*old.decor_mut() = decor;
},
None => {
table.insert(key, toml_edit::value(value));
},
}
}
if let Some(inline) = entry.as_inline_table_mut() {
inline.fmt();
}
true
})
}
pub fn add_layer(&mut self, layer: LayerSpec) -> Result<()> {
if self.layer(&layer.profile.profile).is_some() {
return Err(TargetError::DuplicateLayer { layer: layer.profile.profile }.into());
}
if !self.config.sources.contains_key(&layer.profile.source) {
let sources = self.config.sources.keys().cloned().collect();
let (name, layers) = (layer.profile.source.clone(), vec![layer.profile]);
return Err(TargetError::NoSuchSource { name, sources, layers }.into());
}
let table = ser::to_document(&layer).map_err(io::Error::other)?.into_table();
self.edit("`layers` must be a list of tables", |doc| {
match doc.entry("layers").or_insert_with(|| Item::ArrayOfTables(ArrayOfTables::new())) {
Item::ArrayOfTables(layers) => layers.push(table),
Item::Value(Value::Array(layers)) => layers.push(table.into_inline_table()),
Item::None | Item::Value(_) | Item::Table(_) => return false,
}
true
})
}
#[must_use]
pub fn layer(&self, name: &ProfileName) -> Option<&LayerSpec> {
self.config.layers.iter().find(|layer| layer.profile.profile == *name)
}
pub fn remove_layer(&mut self, name: &ProfileName) -> Result<()> {
let Some(index) = self.config.layers.iter().position(|l| l.profile.profile == *name) else {
return Ok(());
};
self.edit("`layers` must be a list of tables", |doc| {
match doc.get_mut("layers") {
Some(Item::ArrayOfTables(layers)) if index < layers.len() => {
layers.remove(index);
},
Some(Item::Value(Value::Array(layers))) if index < layers.len() => {
layers.remove(index);
},
Some(_) | None => return false,
}
if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
let picks =
|item: &Item| item.get("from").and_then(Item::as_str) == Some(name.as_str());
let picked: Vec<String> = files
.iter()
.filter(|(_, item)| picks(item))
.map(|(key, _)| key.to_owned())
.collect();
for key in picked {
files.remove(&key);
}
}
true
})
}
pub fn turn_on(
&mut self, name: &ProfileName, features: &[FeatureName], defaults: Option<bool>,
) -> Result<(Vec<FeatureName>, Option<bool>)> {
let (index, layer) = self.find_layer(name)?;
let mut listed = layer.features.clone();
let added: Vec<FeatureName> =
features.iter().filter(|feature| !listed.contains(feature)).cloned().collect();
listed.extend(added.iter().cloned());
let defaults = defaults.filter(|&on| on != layer.default_features);
if !added.is_empty() || defaults.is_some() {
self.edit_layer(index, |table| {
set_features(table, &listed);
match defaults {
Some(true) => {
table.remove("default-features");
},
Some(false) => {
table.insert("default-features", toml_edit::value(false));
},
None => {},
}
})?;
}
Ok((added, defaults))
}
pub fn turn_off(&mut self, name: &ProfileName, features: &[FeatureName]) -> Result<()> {
let (index, layer) = self.find_layer(name)?;
if let Some(feature) = features.iter().find(|feature| !layer.features.contains(feature)) {
let (feature, listed) = (feature.clone(), layer.features.clone());
return Err(TargetError::NotListed { layer: name.clone(), feature, listed }.into());
}
let listed: Vec<FeatureName> =
layer.features.into_iter().filter(|feature| !features.contains(feature)).collect();
self.edit_layer(index, |table| set_features(table, &listed))
}
fn find_layer(&self, name: &ProfileName) -> Result<(usize, LayerSpec)> {
let layers = &self.config.layers;
let found = layers.iter().enumerate().find(|(_, layer)| layer.profile.profile == *name);
found.map(|(index, layer)| (index, layer.clone())).ok_or_else(|| {
let names = layers.iter().map(|layer| layer.profile.profile.to_string()).collect();
TargetError::NoSuchLayer { name: name.to_string(), names }.into()
})
}
fn edit_layer(&mut self, index: usize, edit: impl FnOnce(&mut dyn TableLike)) -> Result<()> {
self.edit("`layers` must be a list of tables", |doc| {
let table: &mut dyn TableLike = match doc.get_mut("layers") {
Some(Item::ArrayOfTables(layers)) => match layers.get_mut(index) {
Some(layer) => layer,
None => return false,
},
Some(Item::Value(Value::Array(layers))) => {
match layers.get_mut(index).and_then(Value::as_inline_table_mut) {
Some(layer) => layer,
None => return false,
}
},
Some(_) | None => return false,
};
edit(table);
true
})
}
pub fn remove_override(&mut self, path: &RelPath) -> Result<()> {
self.edit("`files` must be a table", |doc| {
if let Some(files) = doc.get_mut("files").and_then(Item::as_table_like_mut) {
files.remove(path.as_str());
}
true
})
}
fn edit(&mut self, shape: &str, edit: impl FnOnce(&mut DocumentMut) -> bool) -> Result<()> {
let file = label(CONFIG);
let failed = |text: &str, message: String| ParseError {
file: file.clone(),
text: text.to_owned(),
span: None,
message,
};
let mut doc: DocumentMut = self
.config_text
.parse()
.map_err(|e: TomlError| failed(&self.config_text, e.to_string()))?;
let lead = doc.is_empty().then(|| doc.trailing().as_str().map(str::to_owned)).flatten();
if !edit(&mut doc) {
return Err(failed(&self.config_text, shape.to_owned()).into());
}
if let Some(lead) = lead.filter(|lead| !lead.trim().is_empty()) {
doc.set_trailing("");
if let Some((_, first)) = doc.iter_mut().next() {
let prefix = format!("{}\n\n", lead.trim_end());
match first {
Item::Table(table) => table.decor_mut().set_prefix(prefix),
Item::ArrayOfTables(tables) => {
if let Some(table) = tables.get_mut(0) {
table.decor_mut().set_prefix(prefix);
}
},
Item::Value(_) | Item::None => {},
}
}
}
let text = doc.to_string();
self.config = from_toml(text.as_bytes(), &file)?;
self.config_text = text;
Ok(())
}
pub fn answer(&mut self, name: VarName, value: String) {
self.given.insert(name.clone());
self.answers.insert(name, value);
}
#[must_use]
pub const fn answers(&self) -> &BTreeMap<VarName, String> {
&self.answers
}
pub fn rescaffold(&mut self, scaffold: ScaffoldId) {
self.rescaffold.insert(scaffold);
}
pub(crate) const fn rescaffolds(&self) -> &BTreeSet<ScaffoldId> {
&self.rescaffold
}
pub(crate) const fn scaffolds(&self) -> &BTreeMap<ScaffoldId, Scaffold> {
&self.state.scaffolds
}
pub(crate) fn given(&self) -> impl Iterator<Item = &VarName> {
self.given.iter()
}
pub(crate) fn dir(&self) -> Utf8PathBuf {
self.root.join(DIR)
}
pub(crate) fn config_text(&self) -> &str {
&self.config_text
}
fn lock(&self) -> &Lock {
self.pending.as_ref().unwrap_or(&self.lock)
}
#[must_use]
pub fn pinned(&self, source: &Source) -> Option<&Oid> {
let locked = self.lock().profiles.iter();
locked.filter(|locked| locked.source == *source).find_map(|locked| locked.rev.as_ref())
}
pub(crate) fn locked(&self, source: &Source, name: &ProfileName) -> Option<Digest> {
let lock = self.lock();
let locked = lock.profiles.iter().find(|l| l.source == *source && l.name == *name);
locked.map(|locked| locked.digest)
}
pub(crate) fn sidecar(&self, path: &RelPath) -> Utf8PathBuf {
path.under(&self.dir().join(CONFLICTS))
}
pub(crate) fn unfinished(&self) -> Utf8PathBuf {
self.dir().join(CONFLICTS).join(DIR)
}
pub(crate) fn base(&self, record: &Fingerprint) -> Result<Option<Vec<u8>>> {
let blob = read_optional(&self.dir().join(BASE).join(record.exact.to_string()))?;
Ok(blob.filter(|bytes| Digest::of(bytes) == record.exact))
}
pub(crate) fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
self.state.records()
}
pub(crate) const fn state_digest(&self) -> Option<Digest> {
self.state_digest
}
fn load(root: &Utf8Path) -> Result<Self> {
let dir = root.join(DIR);
let config_raw = fs_err::read(dir.join(CONFIG))?;
let config = read_config(&config_raw)?;
let config_text = String::from_utf8(config_raw).map_err(io::Error::other)?;
let state_raw = read_optional(&dir.join(STATE))?;
let state_digest = state_raw.as_deref().map(Digest::of);
let state = state_raw.map(|raw| from_toml(&raw, &label(STATE))).transpose()?;
let lock = |name: &str| -> Result<Option<Lock>> {
let raw = read_optional(&dir.join(name))?;
raw.map(|raw| Lock::read(&raw, &label(name))).transpose()
};
Ok(Self {
root: root.to_owned(),
config,
config_text,
lock: lock(LOCK)?.unwrap_or_default(),
pending: lock(&format!("{CONFLICTS}/{DIR}/{PENDING}"))?,
state: state.unwrap_or_default(),
state_digest,
answers: read_toml(&dir, ANSWERS)?.unwrap_or_default(),
given: BTreeSet::new(),
rescaffold: BTreeSet::new(),
})
}
}
fn set_features(table: &mut dyn TableLike, features: &[FeatureName]) {
if features.is_empty() {
table.remove("features");
return;
}
let mut array: Array = features.iter().map(FeatureName::as_str).collect();
array.fmt();
match table.get_mut("features").and_then(Item::as_value_mut) {
Some(value) => {
let decor = value.decor().clone();
*value = Value::Array(array);
*value.decor_mut() = decor;
},
None => {
table.insert("features", toml_edit::value(array));
},
}
}
fn read_config(raw: &[u8]) -> Result<Config> {
let file = label(CONFIG);
let table: toml::Table = from_toml(raw, &file)?;
let located = |layer: &toml::Value| {
layer
.as_table()
.is_some_and(|layer| layer.contains_key("git") || layer.contains_key("path"))
};
let layers = table.get("layers").and_then(toml::Value::as_array);
if layers.is_some_and(|layers| layers.iter().any(located)) {
return Err(TargetError::OldConfig.into());
}
let config: Config = from_toml(raw, &file)?;
let mut named = BTreeSet::new();
if let Some(layer) = config.layers.iter().find(|layer| !named.insert(&layer.profile.profile)) {
return Err(TargetError::LayerTwice { layer: layer.profile.profile.clone() }.into());
}
Ok(config)
}
pub(crate) fn label(name: &str) -> String {
format!("{DIR}/{name}")
}
fn enclosing(dir: &Utf8Path) -> Option<&Utf8Path> {
dir.ancestors().find(|d| d.join(DIR).join(CONFIG).is_file())
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Lock {
version: V2,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
profiles: Vec<Locked>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Locked {
pub name: ProfileName,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<Oid>,
pub digest: Digest,
pub source: Source,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub features: BTreeMap<FeatureName, Vec<String>>,
}
impl Locked {
pub(crate) fn features(
features: &BTreeMap<FeatureName, BTreeSet<Enabler>>,
) -> BTreeMap<FeatureName, Vec<String>> {
features
.iter()
.map(|(feature, by)| (feature.clone(), by.iter().map(ToString::to_string).collect()))
.collect()
}
}
impl Lock {
pub(crate) const fn new(profiles: Vec<Locked>) -> Self {
Self { version: V2, profiles }
}
fn read(raw: &[u8], file: &str) -> Result<Self> {
let table: toml::Table = from_toml(raw, file)?;
if table.get("version").and_then(toml::Value::as_integer) == Some(1) {
return Ok(Self::default());
}
from_toml(raw, file)
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct State {
version: V2,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
scaffolds: BTreeMap<ScaffoldId, Scaffold>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
files: BTreeMap<RelPath, FileRecord>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
parts: BTreeMap<RelPath, BTreeMap<String, PartRecord>>,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct Record {
pub scope: Scope,
pub fingerprint: Fingerprint,
pub policy: Policy,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileRecord {
exact: Digest,
canonical: Digest,
#[serde(default, skip_serializing_if = "Policy::is_owned")]
policy: Policy,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PartRecord {
scope: Scope,
exact: Digest,
canonical: Digest,
#[serde(default, skip_serializing_if = "Policy::is_owned")]
policy: Policy,
}
impl State {
pub(crate) fn new(
records: BTreeMap<Slot, Record>, scaffolds: BTreeMap<ScaffoldId, Scaffold>,
) -> Self {
let mut state = Self { scaffolds, ..Self::default() };
for (Slot { path, part }, Record { scope, fingerprint, policy }) in records {
let Fingerprint { exact, canonical } = fingerprint;
match part {
None => {
state.files.insert(path, FileRecord { exact, canonical, policy });
},
Some(owner) => {
let record = PartRecord { scope, exact, canonical, policy };
state.parts.entry(path).or_default().insert(owner, record);
},
}
}
state
}
fn records(&self) -> impl Iterator<Item = (Slot, Record)> {
let files = self.files.iter().map(|(path, record)| {
let FileRecord { exact, canonical, policy } = *record;
let fingerprint = Fingerprint { exact, canonical };
(Slot::whole(path.clone()), Record { scope: Scope::File, fingerprint, policy })
});
let parts = self.parts.iter().flat_map(|(path, parts)| {
parts.iter().map(|(owner, record)| {
let slot = Slot { path: path.clone(), part: Some(owner.clone()) };
let PartRecord { scope, exact, canonical, policy } = *record;
(slot, Record { scope, fingerprint: Fingerprint { exact, canonical }, policy })
})
});
files.chain(parts)
}
pub(crate) fn bases(&self) -> impl Iterator<Item = Digest> {
self.records().map(|(_, record)| record.fingerprint.exact)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct V1;
impl Serialize for V1 {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_u8(1)
}
}
impl<'de> Deserialize<'de> for V1 {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
match u8::deserialize(deserializer)? {
1 => Ok(Self),
other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct V2;
impl Serialize for V2 {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_u8(2)
}
}
impl<'de> Deserialize<'de> for V2 {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
match u8::deserialize(deserializer)? {
1 | 2 => Ok(Self),
other => Err(de::Error::custom(format_args!("unsupported format version {other}"))),
}
}
}
pub(crate) fn from_toml<T: DeserializeOwned>(bytes: &[u8], file: &str) -> Result<T> {
let failed = |text: &str, span, message| ParseError {
file: file.into(),
text: text.into(),
span,
message,
};
let text = str::from_utf8(bytes).map_err(|e| failed("", None, e.to_string()))?;
Ok(toml::from_str(text).map_err(|e| failed(text, e.span(), e.message().to_owned()))?)
}
pub(crate) fn read_toml<T: DeserializeOwned>(dir: &Utf8Path, name: &str) -> Result<Option<T>> {
read_optional(&dir.join(name))?.map(|raw| from_toml(&raw, &label(name))).transpose()
}
pub(crate) fn read_optional(path: &Utf8Path) -> Result<Option<Vec<u8>>> {
match fs_err::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::{LayerSpec, Lock, State, Target};
use crate::errors::{Error, TargetError};
use crate::name::SourceName;
use crate::source::{GitRef, Source, SourceSpec};
#[test]
fn a_source_moves_to_another_ref_in_place() {
let dir = camino_tempfile::tempdir().expect("a directory");
let mut target = Target::open_or_new(dir.path()).expect("a new target");
let spec = SourceSpec {
git: Some("https://example.com/p".into()),
tag: Some("v1.0.0".into()),
path: Some("lint".into()),
..SourceSpec::default()
};
let name: SourceName = "p".parse().expect("a name");
target.add_source(name.clone(), Source::try_from(spec).expect("a source")).expect("added");
target.repin(&name, GitRef::Tag("v1.1.0".into())).expect("moved");
let text = target.config_text();
let moved = "p = { git = \"https://example.com/p\", tag = \"v1.1.0\", path = \"lint\" }";
assert!(text.contains(moved), "a tag moves in its place: {text}");
target.repin(&name, GitRef::Branch("main".into())).expect("moved");
let text = target.config_text();
let moved = "p = { git = \"https://example.com/p\", path = \"lint\", branch = \"main\" }";
assert!(text.contains(moved), "another kind of ref replaces it: {text}");
let local: SourceName = "d".parse().expect("a name");
target.add_source(local.clone(), Source::Dir("../d".into())).expect("added");
let refused = target.repin(&local, GitRef::Tag("v1".into()));
assert!(matches!(refused, Err(Error::Target(TargetError::NotGit { .. }))), "{refused:?}");
}
#[test]
fn a_source_written_as_a_table_keeps_its_layout() {
let dir = camino_tempfile::tempdir().expect("a directory");
let config =
"[sources.p]\ngit = \"https://example.com/p\"\ntag = \"v1.0.0\" # the release\n";
fs_err::create_dir_all(dir.path().join(".devset")).expect("a directory");
fs_err::write(dir.path().join(".devset/config.toml"), config).expect("written");
let mut target = Target::find(dir.path()).expect("the target");
target.repin(&"p".parse().expect("a name"), GitRef::Tag("v1.1.0".into())).expect("moved");
let want =
"[sources.p]\ngit = \"https://example.com/p\"\ntag = \"v1.1.0\" # the release\n";
assert_eq!(target.config_text(), want, "only the value changes");
}
#[test]
fn a_new_target_keeps_its_skeleton_above_what_is_added() {
let dir = camino_tempfile::tempdir().expect("a directory");
let mut target = Target::open_or_new(dir.path()).expect("a new target");
target
.add_source("house".parse().expect("a name"), Source::Dir("../p".into()))
.expect("added");
target.add_layer(LayerSpec::new("house/base".parse().expect("a layer"))).expect("added");
let text = target.config_text();
let lead =
"#:schema https://atomix-labs.github.io/devset/schema/config.json\n# devset applies";
assert!(text.starts_with(lead), "the skeleton leads, naming its schema: {text}");
assert!(
text.contains("\n\n[sources]\nhouse = { path = \"../p\" }\n"),
"a source, inline: {text}"
);
assert!(text.ends_with("[[layers]]\nprofile = \"house/base\"\n"), "then the layer: {text}");
let same = target.add_source("house".parse().expect("a name"), Source::Dir("../p".into()));
assert!(same.is_ok(), "naming a source again as it is changes nothing");
let other = target.add_source("house".parse().expect("a name"), Source::Dir("../q".into()));
assert!(matches!(other, Err(Error::Target(TargetError::SourceExists { .. }))), "{other:?}");
let twice = target.add_layer(LayerSpec::new("house/base".parse().expect("a layer")));
assert!(
matches!(twice, Err(Error::Target(TargetError::DuplicateLayer { .. }))),
"{twice:?}"
);
let unknown = target.add_layer(LayerSpec::new("nope/other".parse().expect("a layer")));
assert!(
matches!(unknown, Err(Error::Target(TargetError::NoSuchSource { .. }))),
"{unknown:?}"
);
}
#[test]
fn devset_0_1_records() {
let dir = camino_tempfile::tempdir().expect("a directory");
let devset = dir.path().join(".devset");
fs_err::create_dir_all(&devset).expect("a directory");
fs_err::write(devset.join("config.toml"), "[[layers]]\npath = \"../p\"\n")
.expect("written");
let old = Target::find(dir.path()).expect_err("an old config");
assert!(matches!(old, Error::Target(TargetError::OldConfig)), "refused: {old}");
let lock =
Lock::read(b"version = 1\n[[layers]]\ndigest = \"x\"\n", "lock.toml").expect("read");
assert!(lock.profiles.is_empty(), "an old lock pins nothing");
let state: State = toml::from_str(
"version = 1\n[files.\"a\"]\nexact = \"0000000000000000000000000000000000000000000000000000000000000000\"\ncanonical = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
)
.expect("an old state reads");
assert_eq!(state.records().count(), 1, "with its records");
let written = toml::to_string(&state).expect("written");
assert!(written.starts_with("version = 2\n"), "as version 2: {written}");
}
}