use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CommandSlot {
Build,
Run,
Test,
}
impl CommandSlot {
pub const ALL: [CommandSlot; 3] = [Self::Build, Self::Run, Self::Test];
pub fn key(self) -> &'static str {
match self {
Self::Build => "build",
Self::Run => "run",
Self::Test => "test",
}
}
pub fn setter_command(self) -> String {
format!("/project {}-cmd", self.key())
}
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim().to_ascii_lowercase();
Self::ALL.into_iter().find(|slot| slot.key() == s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Workspace {
pub root: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_cmd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_cmd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub test_cmd: Option<String>,
}
impl Workspace {
pub fn new(root: impl Into<String>) -> Self {
Self {
root: root.into(),
build_cmd: None,
run_cmd: None,
test_cmd: None,
}
}
pub fn command(&self, slot: CommandSlot) -> Option<&str> {
let raw = match slot {
CommandSlot::Build => &self.build_cmd,
CommandSlot::Run => &self.run_cmd,
CommandSlot::Test => &self.test_cmd,
};
raw.as_deref().map(str::trim).filter(|s| !s.is_empty())
}
pub fn set_command(&mut self, slot: CommandSlot, line: Option<String>) {
let line = line.map(|l| l.trim().to_string()).filter(|l| !l.is_empty());
match slot {
CommandSlot::Build => self.build_cmd = line,
CommandSlot::Run => self.run_cmd = line,
CommandSlot::Test => self.test_cmd = line,
}
}
pub fn name(&self) -> &str {
let trimmed = self.root.trim_end_matches(['/', '\\']);
let last = trimmed.rsplit(['/', '\\']).next().unwrap_or("");
if last.is_empty() || last.ends_with(':') {
return &self.root;
}
last
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_is_the_last_component_whatever_the_separator() {
assert_eq!(
Workspace::new("D:/Projects/mindfork-rs").name(),
"mindfork-rs"
);
assert_eq!(Workspace::new("/home/u/app/").name(), "app");
assert_eq!(Workspace::new(r"C:\Projects\app").name(), "app");
assert_eq!(Workspace::new(r"C:\Projects\app\").name(), "app");
}
#[test]
fn a_slot_stores_shows_and_clears_a_line() {
let mut ws = Workspace::new("/p");
for slot in CommandSlot::ALL {
assert_eq!(ws.command(slot), None);
ws.set_command(slot, Some(format!("cargo {}", slot.key())));
assert_eq!(ws.command(slot).unwrap(), format!("cargo {}", slot.key()));
ws.set_command(slot, None);
assert_eq!(ws.command(slot), None);
}
ws.set_command(CommandSlot::Build, Some("cargo build".into()));
assert_eq!(ws.command(CommandSlot::Build), Some("cargo build"));
assert_eq!(ws.command(CommandSlot::Run), None);
assert_eq!(ws.command(CommandSlot::Test), None);
}
#[test]
fn a_blank_line_counts_as_unset() {
let mut ws = Workspace::new("/p");
ws.set_command(CommandSlot::Build, Some(" ".into()));
assert_eq!(ws.command(CommandSlot::Build), None);
assert_eq!(ws.build_cmd, None, "a blank line must not be stored either");
}
#[test]
fn command_slots_are_additive_and_round_trip() {
let stage1: Workspace = serde_json::from_str(r#"{"root":"/p"}"#).unwrap();
assert_eq!(stage1.root, "/p");
assert_eq!(stage1.command(CommandSlot::Build), None);
assert_eq!(
serde_json::to_string(&stage1).unwrap(),
r#"{"root":"/p"}"#,
"a project with no commands must write no new key"
);
let mut ws = Workspace::new("/p");
ws.set_command(CommandSlot::Test, Some("cargo test".into()));
let json = serde_json::to_string(&ws).unwrap();
assert_eq!(serde_json::from_str::<Workspace>(&json).unwrap(), ws);
}
#[test]
fn a_rootless_path_falls_back_to_itself() {
assert_eq!(Workspace::new("/").name(), "/");
assert_eq!(Workspace::new(r"C:\").name(), r"C:\");
}
}