use std::fs;
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use shep_client::shep_core::atomic_file::{create_staging_file, sync_dir};
use shep_client::shep_core::config::AppConfig;
use crate::error::Error;
use crate::settings::Settings;
const MOVED: [&str; 3] = ["branch", "watch", "verify"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Verify {
#[default]
Probed,
Alive,
}
impl Verify {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Probed => "probed",
Self::Alive => "alive",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Watch {
#[default]
Auto,
Manual,
}
impl Watch {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Manual => "manual",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct State {
pub remote: String,
#[serde(skip)]
pub branch: String,
pub deployed: Option<String>,
#[serde(default)]
pub failed: Option<String>,
#[serde(skip)]
pub verify: Verify,
#[serde(skip)]
pub watch: Watch,
pub origin_cwd: Option<PathBuf>,
pub origin_script: Option<String>,
pub checkout: PathBuf,
#[serde(default)]
pub origin: Option<AppConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staged: Option<String>,
}
impl State {
pub fn read(path: &Path) -> Result<Self, Error> {
Self::parse(path, true)
}
pub fn read_for_restore(path: &Path) -> Result<Self, Error> {
Self::parse(path, false)
}
fn parse(path: &Path, refuse_moved: bool) -> Result<Self, Error> {
let text = fs::read_to_string(path).map_err(Error::at(path))?;
let refuse = |source: &dyn core::fmt::Display| {
Error::Config(format!("{}: {source}", path.display()))
};
let mut table: toml::Table = toml::from_str(&text).map_err(|source| refuse(&source))?;
for key in MOVED {
if refuse_moved && table.contains_key(key) {
return Err(refuse(&format!(
"`{key}` moved to `[app.dogs.deploy]` in the sheep's settings. Set it \
there and remove it from this file"
)));
}
table.remove(key);
}
let state: Self = table.try_into().map_err(|source| refuse(&source))?;
state.validate(path)?;
Ok(state)
}
pub fn adopt(&mut self, sheep: &str, settings: &Settings) -> Result<(), Error> {
let Some(branch) = &settings.branch else {
return Err(Error::Config(format!(
"{sheep}: `[app.dogs.deploy]` sets no `branch`, and a branch used to live in \
`deploy.toml`. Set the one this sheep follows"
)));
};
if let Some(why) = branch_refusal(branch) {
return Err(Error::Config(format!(
"{sheep}: `branch` in `[app.dogs.deploy]` {why}"
)));
}
self.branch.clone_from(branch);
self.watch = settings.watch;
self.verify = settings.verify;
Ok(())
}
pub(crate) fn validate(&self, path: &Path) -> Result<(), Error> {
let refuse =
|field: &str, why: &str| Error::Config(format!("{}: `{field}` {why}", path.display()));
if let Some(why) = name_refusal(&self.remote) {
return Err(refuse("remote", why));
}
if self.checkout.as_os_str().is_empty() {
return Err(refuse("checkout", "is empty"));
}
if !self.checkout.is_absolute() {
return Err(refuse(
"checkout",
"is not an absolute path, and a relative one would be resolved against \
wherever the dog happens to be running",
));
}
for (field, value) in [
("deployed", &self.deployed),
("failed", &self.failed),
("staged", &self.staged),
] {
if let Some(sha) = value
&& !is_sha(sha)
{
return Err(refuse(
field,
"is not a full commit sha: 40 (or 64) hexadecimal characters, as `git \
rev-parse` prints one",
));
}
}
if self.deployed.is_some() && self.deployed == self.failed {
return Err(refuse(
"failed",
"names the sha `deployed` names. A release that is serving cannot also be the \
one being held; remove `failed`",
));
}
if self.origin.is_some() && (self.origin_cwd.is_none() || self.origin_script.is_none()) {
return Err(refuse(
"origin",
"is set while origin_cwd or origin_script is missing. All three are written \
together at setup, and removal needs the pair to say where the sheep goes \
back to; set the pair or remove `origin`",
));
}
if self.origin_cwd.is_some() != self.origin_script.is_some() {
let missing = if self.origin_cwd.is_none() {
"origin_cwd"
} else {
"origin_script"
};
return Err(refuse(
missing,
"is missing while its partner is set. Both are written together at setup and \
both are needed to put the sheep back on removal; set both or neither",
));
}
Ok(())
}
pub fn write(&self, path: &Path) -> Result<(), Error> {
self.validate(path)?;
let text = toml::to_string_pretty(self).map_err(|source| Error::Io {
path: path.to_owned(),
source: io::Error::other(source),
})?;
let dir = match path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir,
_ => Path::new("."),
};
let name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("deploy.toml");
let mut staged =
create_staging_file(dir, &format!("{name}."), ".tmp").map_err(Error::at(dir))?;
let written = staged
.as_file_mut()
.write_all(text.as_bytes())
.and_then(|()| staged.as_file().sync_all());
if let Err(source) = written {
return Err(Error::Io {
path: staged.path().to_owned(),
source,
});
}
staged.persist(path).map_err(|failed| Error::Io {
path: path.to_owned(),
source: failed.error,
})?;
sync_dir(dir).map_err(Error::at(dir))
}
}
fn name_refusal(name: &str) -> Option<&'static str> {
if name.trim().is_empty() {
Some("is empty")
} else if name.starts_with('-') {
Some("begins with `-`, which git would read as an option rather than a name")
} else if name.chars().any(crate::shared::forges_a_line) {
Some("carries a control or invisible character that would rewrite a log line")
} else {
None
}
}
#[must_use]
pub(crate) fn branch_refusal(branch: &str) -> Option<&'static str> {
name_refusal(branch).or_else(|| {
branch
.starts_with("refs/")
.then(|| ref_refusal(branch))
.flatten()
})
}
fn ref_refusal(tracked: &str) -> Option<&'static str> {
const CARRIES: [(&str, &str); 11] = [
(" ", "carries a space, which git refuses in a ref name"),
(
":",
"carries `:`, which would split the refspec into a source and a destination",
),
(
"*",
"carries `*`, which would turn the refspec into a pattern",
),
("?", "carries `?`, which git refuses in a ref name"),
("[", "carries `[`, which git refuses in a ref name"),
("\\", "carries `\\`, which git refuses in a ref name"),
("^", "carries `^`, which git refuses in a ref name"),
("~", "carries `~`, which git refuses in a ref name"),
("..", "carries `..`, which git refuses in a ref name"),
("@{", "carries `@{`, which git refuses in a ref name"),
(
"//",
"carries `//`, an empty path component, which git refuses in a ref name",
),
];
if let Some((_, why)) = CARRIES.iter().find(|(needle, _)| tracked.contains(needle)) {
return Some(why);
}
if tracked.ends_with('/') {
return Some("ends with `/`, which git refuses in a ref name");
}
if tracked.ends_with('.') {
return Some("ends with `.`, which git refuses in a ref name");
}
for component in tracked.split('/') {
if component.ends_with(".lock") {
return Some("has a path component ending `.lock`, which git refuses in a ref name");
}
if component.starts_with('.') {
return Some("has a path component beginning `.`, which git refuses in a ref name");
}
}
None
}
#[must_use]
pub fn is_sha(text: &str) -> bool {
matches!(text.len(), 40 | 64) && text.bytes().all(|byte| byte.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests;