use std::path::Path;
use shep_client::shep_core::config::AppConfig;
use crate::daemon::Daemon;
use crate::error::Error;
use crate::paths::Tree;
use crate::roll;
use crate::state::{State, Watch};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Standing {
Watched {
branch: String,
sha: Option<String>,
},
Held {
branch: String,
sha: Option<String>,
failed: String,
},
Manual {
branch: String,
sha: Option<String>,
},
NeedsSetup,
Eligible,
NotEligible(String),
}
#[must_use]
pub fn classify(shep_home: &Path, app: &AppConfig) -> Standing {
let tree = Tree::for_sheep(shep_home, &app.name);
if let Ok(state) = State::read(&tree.state_file()) {
let (branch, sha) = (state.branch, state.deployed);
return match (state.watch, state.failed) {
(Watch::Auto, Some(failed)) => Standing::Held {
branch,
sha,
failed,
},
(Watch::Auto, None) => Standing::Watched { branch, sha },
(Watch::Manual, _) => Standing::Manual { branch, sha },
};
}
let Some(cwd) = app.cwd.as_deref() else {
return Standing::NotEligible(
"shep records no working directory for it, so there is nothing to inspect".to_owned(),
);
};
let checkout = Path::new(cwd);
if !checkout.join(".git").exists() {
return Standing::NotEligible(format!("{cwd} is not a git repository"));
}
if checkout.join("Flockfile.toml").is_file() {
Standing::NeedsSetup
} else {
Standing::Eligible
}
}
#[must_use]
pub fn render(rows: &[(String, Standing)]) -> String {
if rows.is_empty() {
return "no sheep are registered, so there is nothing to survey\n".to_owned();
}
let name_width = rows.iter().map(|(name, _)| name.len()).max().unwrap_or(0) + 2;
let label_width = rows
.iter()
.map(|(_, standing)| standing.label().len())
.max()
.unwrap_or(0)
+ 2;
rows.iter()
.map(|(name, standing)| {
format!(
"{name:name_width$}{:label_width$}{}\n",
standing.label(),
standing.reason()
)
})
.collect()
}
impl Standing {
fn label(&self) -> &'static str {
match self {
Self::Watched { .. } => "watched",
Self::Held { .. } => "held",
Self::Manual { .. } => "manual",
Self::NeedsSetup => "needs setup",
Self::Eligible => "eligible",
Self::NotEligible(_) => "not eligible",
}
}
fn reason(&self) -> String {
match self {
Self::Watched { branch, sha } => {
format!(
"{}, deploys on every new commit",
at(branch, sha.as_deref())
)
}
Self::Held {
branch,
sha,
failed,
} => format!(
"{}, holding {} after it did not land. A newer commit or a landing deploy \
clears it",
at(branch, sha.as_deref()),
short(failed)
),
Self::Manual { branch, sha } => {
format!("{}, deploys only when asked", at(branch, sha.as_deref()))
}
Self::NeedsSetup => "a git checkout that ships a Flockfile".to_owned(),
Self::Eligible => "a git checkout, nothing declares a deploy".to_owned(),
Self::NotEligible(why) => why.clone(),
}
}
}
fn at(branch: &str, sha: Option<&str>) -> String {
sha.map_or_else(
|| format!("{branch}, not deployed yet"),
|sha| format!("{branch}@{}", short(sha)),
)
}
fn short(sha: &str) -> &str {
sha.get(..6).unwrap_or(sha)
}
pub async fn survey<D: Daemon>(daemon: &D, shep_home: &Path) -> Result<String, Error> {
let apps = roll::registered(daemon).await?;
let rows: Vec<(String, Standing)> = apps
.values()
.map(|app| (app.name.clone(), classify(shep_home, app)))
.collect();
Ok(render(&rows))
}
#[cfg(test)]
mod tests {
use super::*;
fn app(name: &str, cwd: Option<&str>) -> AppConfig {
let mut app: AppConfig =
toml::from_str(&format!("name = {name:?}\nscript = \"./run.sh\"")).expect("parses");
app.cwd = cwd.map(str::to_owned);
app
}
fn checkout_fixture(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let status = std::process::Command::new("git")
.arg("init")
.arg("-q")
.arg(dir.path())
.status()
.expect("git is on PATH");
assert!(status.success(), "git init failed");
for (key, value) in [("user.email", "test@example.com"), ("user.name", "test")] {
let status = std::process::Command::new("git")
.arg("-C")
.arg(dir.path())
.arg("config")
.arg(key)
.arg(value)
.status()
.expect("git is on PATH");
assert!(status.success(), "git config {key} failed");
}
for (name, contents) in files {
std::fs::write(dir.path().join(name), contents).expect("write fixture file");
}
dir
}
fn write_target(
home: &Path,
sheep: &str,
watch: Watch,
sha: Option<&str>,
failed: Option<&str>,
) {
let tree = Tree::for_sheep(home, sheep);
std::fs::create_dir_all(tree.state_file().parent().expect("has a parent"))
.expect("create target dir");
let state = State {
remote: "https://example.com/x".to_owned(),
branch: "main".to_owned(),
deployed: sha.map(str::to_owned),
failed: failed.map(str::to_owned),
verify: crate::state::Verify::default(),
watch,
origin_cwd: None,
origin_script: None,
checkout: std::path::PathBuf::from("/srv/x"),
};
state.write(&tree.state_file()).expect("write state");
}
#[test]
fn a_cwd_that_is_not_a_checkout_is_not_eligible_and_says_why() {
let home = tempfile::tempdir().expect("tempdir");
let plain = tempfile::tempdir().expect("tempdir");
let standing = classify(home.path(), &app("legacy", plain.path().to_str()));
let Standing::NotEligible(why) = standing else {
panic!("expected NotEligible, got {standing:?}");
};
assert!(why.contains(plain.path().to_str().expect("utf-8")), "{why}");
assert!(why.contains("git"), "{why}");
}
#[test]
fn a_sheep_with_no_recorded_cwd_is_not_eligible() {
let home = tempfile::tempdir().expect("tempdir");
assert!(matches!(
classify(home.path(), &app("odd", None)),
Standing::NotEligible(_)
));
}
#[test]
fn a_checkout_shipping_a_flockfile_needs_setup_and_one_without_is_merely_eligible() {
let home = tempfile::tempdir().expect("tempdir");
let declared = checkout_fixture(&[("Flockfile.toml", "[[app]]\nname='x'\nscript='y'\n")]);
assert!(matches!(
classify(home.path(), &app("reactmap", declared.path().to_str())),
Standing::NeedsSetup
));
let bare = checkout_fixture(&[]);
assert!(matches!(
classify(home.path(), &app("koji", bare.path().to_str())),
Standing::Eligible
));
}
#[test]
fn an_existing_target_reports_its_watch_mode_not_its_eligibility() {
let home = tempfile::tempdir().expect("tempdir");
let current = tempfile::tempdir().expect("tempdir");
write_target(
home.path(),
"bpm",
Watch::Manual,
Some("a1b2c3d4e5f6"),
None,
);
let standing = classify(home.path(), &app("bpm", current.path().to_str()));
assert!(matches!(standing, Standing::Manual { .. }), "{standing:?}");
}
#[test]
fn a_worktree_checkout_is_still_a_checkout() {
let home = tempfile::tempdir().expect("tempdir");
let origin = checkout_fixture(&[]);
let worktree = tempfile::tempdir().expect("tempdir");
let status = std::process::Command::new("git")
.arg("-C")
.arg(origin.path())
.arg("commit")
.arg("--allow-empty")
.arg("-q")
.arg("-m")
.arg("init")
.status()
.expect("git is on PATH");
assert!(status.success(), "git commit failed");
std::fs::remove_dir(worktree.path()).expect("remove tempdir stand-in");
let status = std::process::Command::new("git")
.arg("-C")
.arg(origin.path())
.arg("worktree")
.arg("add")
.arg("--detach")
.arg(worktree.path())
.status()
.expect("git is on PATH");
assert!(status.success(), "git worktree add failed");
assert!(worktree.path().join(".git").is_file(), "not a worktree");
assert!(matches!(
classify(home.path(), &app("bpm", worktree.path().to_str())),
Standing::Eligible
));
}
#[test]
fn a_held_target_says_what_it_is_holding_and_what_clears_it() {
let home = tempfile::tempdir().expect("tempdir");
let current = tempfile::tempdir().expect("tempdir");
write_target(
home.path(),
"bpm",
Watch::Auto,
Some("a1b2c3d4e5f6"),
Some("d4e5f6a7b8c9"),
);
let row = render(&[(
"bpm".to_owned(),
classify(home.path(), &app("bpm", current.path().to_str())),
)]);
assert!(row.contains("held"), "{row}");
assert!(row.contains("a1b2c3"), "what is still serving: {row}");
assert!(row.contains("d4e5f6"), "what it is holding: {row}");
assert!(row.contains("did not land"), "why it is holding: {row}");
assert!(row.contains("newer commit"), "what clears it: {row}");
assert!(
!row.contains("deploys on every new commit"),
"that is the line this row is not: {row}"
);
}
#[test]
fn a_manual_target_with_a_failed_sha_is_still_manual() {
let home = tempfile::tempdir().expect("tempdir");
let current = tempfile::tempdir().expect("tempdir");
write_target(
home.path(),
"bpm",
Watch::Manual,
Some("a1b2c3d4e5f6"),
Some("d4e5f6a7b8c9"),
);
let row = render(&[(
"bpm".to_owned(),
classify(home.path(), &app("bpm", current.path().to_str())),
)]);
assert!(row.contains("only when asked"), "{row}");
assert!(!row.contains("held"), "{row}");
}
#[test]
fn the_rendered_survey_is_three_aligned_columns() {
let rows = vec![
(
"bpm".to_owned(),
Standing::Watched {
branch: "main".to_owned(),
sha: Some("a1b2c3d4e5f6".to_owned()),
},
),
(
"koji-staging".to_owned(),
Standing::Manual {
branch: "main".to_owned(),
sha: Some("a1b2c3d4e5f6".to_owned()),
},
),
(
"reactmap-eu".to_owned(),
Standing::Held {
branch: "main".to_owned(),
sha: Some("a1b2c3d4e5f6".to_owned()),
failed: "d4e5f6a7b8c9".to_owned(),
},
),
("reactmap".to_owned(), Standing::NeedsSetup),
("koji".to_owned(), Standing::Eligible),
(
"legacy".to_owned(),
Standing::NotEligible("/opt/legacy is not a git repository".to_owned()),
),
];
assert_eq!(
render(&rows),
"bpm watched main@a1b2c3, deploys on every new commit\n\
koji-staging manual main@a1b2c3, deploys only when asked\n\
reactmap-eu held main@a1b2c3, holding d4e5f6 after it did not land. A \
newer commit or a landing deploy clears it\n\
reactmap needs setup a git checkout that ships a Flockfile\n\
koji eligible a git checkout, nothing declares a deploy\n\
legacy not eligible /opt/legacy is not a git repository\n"
);
}
#[test]
fn an_empty_flock_says_so() {
assert!(render(&[]).contains("no sheep"));
}
}