use anyhow::{Result, bail};
use dialoguer::Input;
use serde::Serialize;
use crate::cli::{Format, PathCommand};
use crate::model::{normalize_remote_path, validate_name, validate_remote_path};
use crate::{output, pick, store};
pub fn run(command: PathCommand) -> Result<()> {
match command {
PathCommand::Add { name, dir, restart } => add(name, dir, restart),
PathCommand::List { format } => list(format),
PathCommand::Show { name, format } => show(&resolve(name, "Show path")?, format),
PathCommand::Edit { name, dir, restart } => {
let name = resolve(name, "Edit path")?;
edit(&name, dir, restart)
}
PathCommand::Remove { name, assume_yes } => {
let name = resolve(name, "Remove path")?;
remove(&name, assume_yes)
}
}
}
fn resolve(name: Option<String>, prompt: &str) -> Result<String> {
match name {
Some(name) => Ok(name),
None => pick::path(&store::load_paths()?, prompt),
}
}
fn add(name: Option<String>, dir: Option<String>, restart: Option<String>) -> Result<()> {
let mut paths = store::load_paths()?;
let wizard = name.is_none() || dir.is_none();
if wizard {
pick::ensure_interactive("path name and --dir are required")?;
}
let name = match name {
Some(name) => name,
None => Input::new()
.with_prompt("Path name")
.validate_with(|s: &String| validate_name(s).map_err(|e| e.to_string()))
.interact_text()?,
};
validate_name(&name)?;
if paths.iter().any(|p| p.name == name) {
bail!("path '{name}' already exists");
}
let dir = match dir {
Some(dir) => dir,
None => Input::new()
.with_prompt("Remote directory")
.validate_with(|s: &String| validate_remote_path(s).map_err(|e| e.to_string()))
.interact_text()?,
};
validate_remote_path(&dir)?;
let restart = match restart {
Some(restart) => Some(restart),
None if wizard => {
let answer: String = Input::new()
.with_prompt("Command to run after writing here (empty for none)")
.allow_empty(true)
.interact_text()?;
Some(answer)
}
None => None,
};
paths.push(crate::model::Path {
name: name.clone(),
dir: normalize_remote_path(&dir),
restart: restart.filter(|r| !r.trim().is_empty()).map(|r| r.trim().to_string()),
});
paths.sort_by(|a, b| a.name.cmp(&b.name));
store::save_paths(&paths)?;
crate::journal::record("path.add", None, None, Some(&name));
println!("Path '{name}' added.");
Ok(())
}
#[derive(Serialize)]
pub struct PathView {
pub name: String,
pub dir: String,
pub restart: Option<String>,
pub used_by: Vec<String>,
}
pub fn views() -> Result<Vec<PathView>> {
let targets = store::load_targets()?;
Ok(store::load_paths()?
.into_iter()
.map(|path| PathView {
used_by: targets.iter().filter(|t| t.path == path.name).map(|t| t.name.clone()).collect(),
name: path.name,
dir: path.dir,
restart: path.restart,
})
.collect())
}
fn list(format: Format) -> Result<()> {
output::emit(format, &views()?, |paths| {
if paths.is_empty() {
return "No paths yet - run `turnout path add`.".to_string();
}
let width = paths.iter().map(|p| p.name.len()).max().unwrap_or(0);
let lines: Vec<String> = paths
.iter()
.map(|path| match &path.restart {
Some(restart) => format!("{:width$} {} (then: {restart})", path.name, path.dir),
None => format!("{:width$} {}", path.name, path.dir),
})
.collect();
lines.join("\n")
})
}
fn show(name: &str, format: Format) -> Result<()> {
let path = views()?.into_iter().find(|p| p.name == name).ok_or_else(|| unknown(name))?;
output::emit(format, &path, |path| {
let mut lines = vec![path.name.clone(), format!(" Directory: {}", path.dir)];
lines.push(match &path.restart {
Some(restart) => format!(" After: {restart}"),
None => " After: nothing".to_string(),
});
if !path.used_by.is_empty() {
lines.push(format!(" Used by: {}", path.used_by.join(", ")));
}
lines.join("\n")
})
}
fn used_by(name: &str) -> Result<Vec<String>> {
Ok(store::load_targets()?.into_iter().filter(|t| t.path == name).map(|t| t.name).collect())
}
fn edit(name: &str, dir: Option<String>, restart: Option<String>) -> Result<()> {
let mut paths = store::load_paths()?;
let index = paths.iter().position(|p| p.name == name).ok_or_else(|| unknown(name))?;
if dir.is_none() && restart.is_none() {
pick::ensure_interactive("nothing to change: pass flags to edit non-interactively")?;
let path = &mut paths[index];
let dir: String = Input::new()
.with_prompt("Remote directory")
.default(path.dir.clone())
.validate_with(|s: &String| validate_remote_path(s).map_err(|e| e.to_string()))
.interact_text()?;
path.dir = normalize_remote_path(&dir);
let restart: String = Input::new()
.with_prompt("Command to run after writing here (empty for none)")
.default(path.restart.clone().unwrap_or_default())
.allow_empty(true)
.interact_text()?;
path.restart = if restart.trim().is_empty() { None } else { Some(restart.trim().to_string()) };
} else {
let path = &mut paths[index];
if let Some(dir) = dir {
validate_remote_path(&dir)?;
path.dir = normalize_remote_path(&dir);
}
if let Some(restart) = restart {
path.restart = if restart.trim().is_empty() { None } else { Some(restart.trim().to_string()) };
}
}
store::save_paths(&paths)?;
crate::journal::record("path.edit", None, None, Some(name));
println!("Path '{name}' updated.");
Ok(())
}
fn remove(name: &str, assume_yes: bool) -> Result<()> {
let mut paths = store::load_paths()?;
if !paths.iter().any(|p| p.name == name) {
return Err(unknown(name));
}
let used_by = used_by(name)?;
if !used_by.is_empty() {
println!("Used by targets: {}. They will be removed with it.", used_by.join(", "));
}
let confirmed = pick::confirm_destructive(format!("Remove path '{name}' from the catalog?"), assume_yes)?;
if !confirmed {
println!("Cancelled.");
return Ok(());
}
paths.retain(|p| p.name != name);
store::save_paths(&paths)?;
let mut targets = store::load_targets()?;
let before = targets.len();
targets.retain(|t| t.path != name);
if targets.len() != before {
store::save_targets(&targets)?;
}
crate::journal::record("path.remove", None, None, Some(name));
println!("Path '{name}' removed from the catalog (the directory on the server is untouched).");
Ok(())
}
fn unknown(name: &str) -> anyhow::Error {
anyhow::anyhow!("no path named '{name}' - see `turnout path list`")
}