use alloc::collections::{BTreeMap, BTreeSet};
use core::str;
use std::io;
use camino::{Utf8Path, Utf8PathBuf};
use schemars::JsonSchema;
use serde::de::{self, DeserializeOwned};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use toml_edit::{ArrayOfTables, DocumentMut, Item, TomlError, Value, ser};
use crate::digest::{Digest, Fingerprint};
use crate::errors::{ParseError, Result, TargetError};
use crate::part::{Scope, Slot};
use crate::path::RelPath;
use crate::profile::{Format, MergeSpec, Policy};
use crate::source::{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>,
state_digest: Option<Digest>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub layers: Vec<Source>,
#[serde(default)]
pub merge: MergeSpec,
#[serde(default)]
pub files: BTreeMap<RelPath, Override>,
}
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Override {
#[serde(default)]
pub from: Option<String>,
#[serde(default)]
pub policy: Option<Policy>,
#[serde(default)]
pub validate: Option<Format>,
}
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: String::new(),
lock: Lock::default(),
pending: None,
state: State::default(),
answers: BTreeMap::new(),
given: BTreeSet::new(),
state_digest: None,
}),
}
}
#[must_use]
pub fn root(&self) -> &Utf8Path {
&self.root
}
#[must_use]
pub const fn config(&self) -> &Config {
&self.config
}
pub fn add_layer(&mut self, source: Source) -> Result<()> {
if self.config.layers.contains(&source) {
return Err(TargetError::DuplicateLayer { layer: source.to_string() }.into());
}
let table =
ser::to_document(&SourceSpec::from(source)).map_err(io::Error::other)?.into_table();
self.edit(|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: &str) -> Option<&Source> {
let lock = self.pending.as_ref().unwrap_or(&self.lock);
let locked = lock.layers.iter().find(|locked| locked.name.as_deref() == Some(name))?;
self.config.layers.iter().find(|source| **source == locked.source)
}
pub fn remove_layer(&mut self, source: &Source, name: &str) -> Result<()> {
let Some(index) = self.config.layers.iter().position(|layer| layer == source) else {
return Ok(());
};
self.edit(|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);
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 remove_override(&mut self, path: &RelPath) -> Result<()> {
self.edit(|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, 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()))?;
if !edit(&mut doc) {
let message = "`layers` must be a list of tables".to_owned();
return Err(failed(&self.config_text, message).into());
}
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(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
}
pub(crate) fn locked(&self, source: &Source) -> Option<&Locked> {
let lock = self.pending.as_ref().unwrap_or(&self.lock);
lock.layers.iter().find(|locked| locked.source == *source)
}
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 = from_toml(&config_raw, &label(CONFIG))?;
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()?;
Ok(Self {
root: root.to_owned(),
config,
config_text,
lock: read_toml(&dir, LOCK)?.unwrap_or_default(),
pending: read_toml(&dir, &format!("{CONFLICTS}/{DIR}/{PENDING}"))?,
state: state.unwrap_or_default(),
state_digest,
answers: read_toml(&dir, ANSWERS)?.unwrap_or_default(),
given: BTreeSet::new(),
})
}
}
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: V1,
#[serde(default)]
layers: Vec<Locked>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct Locked {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rev: Option<Oid>,
pub digest: Digest,
pub source: Source,
}
impl Lock {
pub(crate) const fn new(layers: Vec<Locked>) -> Self {
Self { version: V1, layers }
}
}
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct State {
version: V1,
#[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>) -> Self {
let mut state = 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}"))),
}
}
}
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()),
}
}