use std::{
env, fs,
path::{Path, PathBuf},
process::Command,
};
use alix::config::{Audience, Config};
use anyhow::{Context, Result, bail};
use crate::{ProfileAddArgs, ProfileCommand, ProfileDefaultArgs, common::confirm};
pub(crate) const RESERVED: [&str; 4] = ["add", "list", "remove", "default"];
pub(crate) fn profiles_dir() -> Result<PathBuf> {
alix::config::profiles_dir().context("cannot determine the alix config directory")
}
pub(crate) fn config_path_in(dir: &Path, name: &str) -> PathBuf {
dir.join(format!("{name}.toml"))
}
pub(crate) fn default_marker_in(dir: &Path) -> PathBuf {
dir.join("default")
}
pub(crate) fn validate_name(name: &str) -> Result<()> {
if name.is_empty() || name.contains(['/', '\\', '.']) {
bail!("a profile name cannot be empty or contain `/`, `\\`, or `.`");
}
if RESERVED.contains(&name) {
bail!(
"`{name}` is a reserved profile command; pick another name (reserved: {})",
RESERVED.join(", ")
);
}
Ok(())
}
pub(crate) fn run(cmd: ProfileCommand) -> Result<()> {
match cmd {
ProfileCommand::Add(args) => add(args),
ProfileCommand::List => list(),
ProfileCommand::Remove(args) => remove(&args.name, args.yes),
ProfileCommand::Default(args) => default_cmd(args),
ProfileCommand::Launch(args) => launch_named(args),
}
}
fn add(args: ProfileAddArgs) -> Result<()> {
let dir = profiles_dir()?;
let config = Config::load(None)?;
add_in(&dir, &args, &config)
}
fn add_in(dir: &Path, args: &ProfileAddArgs, config: &Config) -> Result<()> {
validate_name(&args.name)?;
let path = config_path_in(dir, &args.name);
if path.exists() {
bail!("profile `{}` already exists; remove it first", args.name);
}
let decks = args
.decks
.clone()
.or_else(|| config.decks_dir())
.context("cannot determine the decks directory")?;
let decks = decks
.to_str()
.context("the decks path is not valid UTF-8")?;
let port = args.port.unwrap_or(config.serve.port);
let audience = match (args.kids, args.adult) {
(true, _) => "kids",
_ => "adult",
};
let token = crate::launch::generate_token()?;
let toml = format!(
"decks_dir = {decks:?}\n\n[serve]\nport = {port}\naudience = \"{audience}\"\ntoken = \"{token}\"\n"
);
fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
write_atomic(&path, &toml)?;
println!("wrote profile `{}` to {}", args.name, path.display());
if args.kids {
println!("this profile launches on your LAN with a stable token; bookmark its printed URL");
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
struct ProfileRow {
name: String,
audience: Audience,
port: u16,
decks: PathBuf,
config: PathBuf,
}
fn list() -> Result<()> {
let dir = profiles_dir()?;
let rows = profile_rows_in(&dir)?;
if rows.is_empty() {
println!("no profiles yet; create one with `alix profile add <name>`");
return Ok(());
}
let name_width = rows
.iter()
.map(|row| row.name.len())
.max()
.unwrap_or_default()
.max("name".len());
let decks_width = rows
.iter()
.map(|row| row.decks.display().to_string().len())
.max()
.unwrap_or_default()
.max("decks".len());
println!(
"{:<name_width$} {:<6} {:>5} {:<decks_width$} config",
"name", "flavor", "port", "decks"
);
for row in rows {
let audience = match row.audience {
Audience::Adult => "adult",
Audience::Kids => "kids",
};
println!(
"{:<name_width$} {audience:<6} {:>5} {:<decks_width$} {}",
row.name,
row.port,
row.decks.display().to_string(),
row.config.display()
);
}
Ok(())
}
fn profile_rows_in(dir: &Path) -> Result<Vec<ProfileRow>> {
let mut rows = Vec::new();
for path in profile_paths_in(dir)? {
let name = path
.file_stem()
.and_then(|name| name.to_str())
.context("a profile filename is not valid UTF-8")?
.to_string();
let config = Config::load(Some(&path))?;
let decks = config
.decks_dir()
.context("cannot determine the decks directory")?;
rows.push(ProfileRow {
name,
audience: config.serve.audience,
port: config.serve.port,
decks,
config: path,
});
}
Ok(rows)
}
fn profile_paths_in(dir: &Path) -> Result<Vec<PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut paths = Vec::new();
for entry in fs::read_dir(dir).with_context(|| format!("cannot read {}", dir.display()))? {
let path = entry?.path();
if path.is_file()
&& path
.extension()
.is_some_and(|extension| extension == "toml")
{
paths.push(path);
}
}
paths.sort();
Ok(paths)
}
fn remove(name: &str, yes: bool) -> Result<()> {
let dir = profiles_dir()?;
remove_in(&dir, name, yes)
}
fn remove_in(dir: &Path, name: &str, yes: bool) -> Result<()> {
validate_name(name)?;
let path = config_path_in(dir, name);
if !path.exists() {
bail!("no profile `{name}` (looked in {})", path.display());
}
if !confirm(&format!("Delete profile `{name}`?"), yes)? {
return Ok(());
}
fs::remove_file(&path).with_context(|| format!("cannot remove {}", path.display()))?;
println!("removed profile `{name}`");
Ok(())
}
fn default_cmd(args: ProfileDefaultArgs) -> Result<()> {
let dir = profiles_dir()?;
default_cmd_in(&dir, args)
}
fn default_cmd_in(dir: &Path, args: ProfileDefaultArgs) -> Result<()> {
let marker = default_marker_in(dir);
if args.clear {
if marker.exists() {
fs::remove_file(&marker)
.with_context(|| format!("cannot remove {}", marker.display()))?;
}
println!("default profile cleared");
return Ok(());
}
if let Some(name) = args.name {
validate_name(&name)?;
let path = config_path_in(dir, &name);
if !path.exists() {
bail!("no profile `{name}` (looked in {})", path.display());
}
fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
write_atomic(&marker, &format!("{name}\n"))?;
println!("default profile: {name}");
return Ok(());
}
match read_default_in(dir)? {
Some(name) => println!("{name}"),
None => println!("none"),
}
Ok(())
}
fn read_default_in(dir: &Path) -> Result<Option<String>> {
let marker = default_marker_in(dir);
if !marker.exists() {
return Ok(None);
}
let name =
fs::read_to_string(&marker).with_context(|| format!("cannot read {}", marker.display()))?;
let name = name.trim();
if name.is_empty() {
Ok(None)
} else {
Ok(Some(name.to_string()))
}
}
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
let tmp = path.with_extension("tmp");
fs::write(&tmp, contents).with_context(|| format!("cannot write {}", path.display()))?;
fs::rename(&tmp, path).with_context(|| format!("cannot write {}", path.display()))
}
fn launch_named(args: Vec<String>) -> Result<()> {
if args.len() != 1 {
bail!("unknown profile command `{}`", args.join(" "));
}
launch_profile(&args[0])
}
pub(crate) fn launch_profile(name: &str) -> Result<()> {
launch_profile_with_log(name, Vec::new())
}
pub(crate) fn launch_profile_with_log(name: &str, log: Vec<alix::log::Target>) -> Result<()> {
validate_name(name)?;
let path = config_path_in(&profiles_dir()?, name);
if !path.exists() {
bail!("no profile `{name}` (looked in {})", path.display());
}
crate::launch::launch(
crate::LaunchArgs {
dir: None,
port: None,
lan: true,
token: None,
session: None,
config: Some(path),
log,
launch_all: false,
},
name,
)
}
pub(crate) fn instance_name_for_launch(config: Option<&Path>, dir: Option<&Path>) -> String {
if let Some(name) = config
.filter(|path| {
path.parent()
.and_then(Path::file_name)
.is_some_and(|name| name == "profiles")
})
.and_then(Path::file_stem)
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
{
return name.to_string();
}
if let Some(path) = config {
return path_instance("config", path);
}
if let Some(path) = dir {
return path_instance("scoped", path);
}
"default".into()
}
fn path_instance(kind: &str, path: &Path) -> String {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
format!("{kind}:{}", canonical.display())
}
pub(crate) fn resolve_default() -> Result<Option<String>> {
resolve_default_in(&profiles_dir()?)
}
fn resolve_default_in(dir: &Path) -> Result<Option<String>> {
let Some(name) = read_default_in(dir)? else {
return Ok(None);
};
validate_name(&name)?;
let path = config_path_in(dir, &name);
if !path.exists() {
bail!(
"default profile `{name}` does not exist (looked in {})",
path.display()
);
}
Ok(Some(name))
}
pub(crate) fn launch_all(log: &[alix::log::Target]) -> Result<()> {
let dir = profiles_dir()?;
let plan = plan_launch_all(&dir)?;
if plan.is_empty() {
println!("no profiles to launch; create one with `alix profile add <name>`");
return Ok(());
}
let name_width = plan
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or_default();
let host = crate::launch::local_lan_ip()
.map(|ip| ip.to_string())
.unwrap_or_else(|| "<this-machine's-IP>".to_string());
let executable = env::current_exe().context("cannot determine the alix executable")?;
let mut children = Vec::new();
for (name, port) in plan {
println!("{name:<name_width$} -> http://{host}:{port}");
match Command::new(&executable)
.args(launch_all_child_args(&name, log))
.spawn()
{
Ok(child) => children.push((name, child)),
Err(error) => eprintln!("profile `{name}` failed to launch: {error}"),
}
}
for (name, mut child) in children {
if let Some(message) = child_outcome(&name, child.wait()) {
eprintln!("{message}");
}
}
Ok(())
}
fn launch_all_child_args(name: &str, log: &[alix::log::Target]) -> Vec<String> {
let mut args = Vec::new();
if !log.is_empty() {
args.push("--log".into());
args.push(
log.iter()
.map(|target| target.name())
.collect::<Vec<_>>()
.join(","),
);
}
args.push("profile".into());
args.push(name.into());
args
}
fn child_outcome(name: &str, waited: std::io::Result<std::process::ExitStatus>) -> Option<String> {
match waited {
Ok(status) if !status.success() => Some(format!("profile `{name}` exited with {status}")),
Err(error) => Some(format!("could not wait for profile `{name}`: {error}")),
_ => None,
}
}
fn plan_launch_all(dir: &Path) -> Result<Vec<(String, u16)>> {
let mut plan = Vec::new();
for path in profile_paths_in(dir)? {
let name = path
.file_stem()
.and_then(|name| name.to_str())
.context("a profile filename is not valid UTF-8")?
.to_string();
let config = Config::load(Some(&path))?;
plan.push((name, config.serve.port));
}
Ok(plan)
}
#[cfg(test)]
mod tests {
use alix::config::ServeConfig;
use tempfile::TempDir;
use super::*;
#[test]
fn reserved_verbs_are_rejected_as_names() {
for verb in RESERVED {
assert!(validate_name(verb).is_err(), "{verb} should be reserved");
}
assert!(validate_name("timmy").is_ok());
assert!(validate_name("").is_err());
assert!(validate_name("a/b").is_err());
}
#[test]
fn config_path_is_name_dot_toml_under_the_dir() {
let dir = Path::new("/cfg/profiles");
assert_eq!(dir.join("timmy.toml"), config_path_in(dir, "timmy"));
assert_eq!(dir.join("default"), default_marker_in(dir));
}
#[test]
fn launch_inputs_select_stable_distinct_log_instances_without_exposing_paths() {
assert_eq!(
"timmy",
instance_name_for_launch(Some(Path::new("/cfg/profiles/timmy.toml")), None)
);
let first = instance_name_for_launch(Some(Path::new("/one/config.toml")), None);
let second = instance_name_for_launch(Some(Path::new("/two/config.toml")), None);
assert!(first.starts_with("config:") && second.starts_with("config:"));
assert_ne!(
alix::log::log_path(&first),
alix::log::log_path(&second),
"independent config instances must never share a writer"
);
assert!(
instance_name_for_launch(None, Some(Path::new("/private/decks")))
.starts_with("scoped:")
);
assert_eq!("default", instance_name_for_launch(None, None));
}
#[test]
fn profile_management_round_trips_configs_rows_and_removal() {
let temp = TempDir::new().unwrap();
let dir = temp.path().join("profiles");
let decks = temp.path().join("timmy-decks");
let config = Config {
decks_dir: Some(temp.path().join("fallback-decks")),
serve: ServeConfig {
port: 9000,
..ServeConfig::default()
},
..Config::default()
};
let args = ProfileAddArgs {
name: "timmy".to_string(),
decks: Some(decks.clone()),
port: Some(7002),
kids: true,
adult: false,
};
add_in(&dir, &args, &config).unwrap();
let path = config_path_in(&dir, "timmy");
let loaded = Config::load(Some(&path)).unwrap();
assert_eq!(Some(decks.clone()), loaded.decks_dir);
assert_eq!(7002, loaded.serve.port);
assert_eq!(Audience::Kids, loaded.serve.audience);
assert!(loaded.serve.token.is_some_and(|token| !token.is_empty()));
assert!(add_in(&dir, &args, &config).is_err());
assert_eq!(
vec![ProfileRow {
name: "timmy".to_string(),
audience: Audience::Kids,
port: 7002,
decks,
config: path.clone(),
}],
profile_rows_in(&dir).unwrap()
);
remove_in(&dir, "timmy", true).unwrap();
assert!(!path.exists());
assert!(profile_rows_in(&dir).unwrap().is_empty());
}
#[test]
fn add_rejects_a_reserved_profile_name() {
let temp = TempDir::new().unwrap();
let config = Config {
decks_dir: Some(temp.path().join("decks")),
..Config::default()
};
let args = ProfileAddArgs {
name: "add".to_string(),
decks: None,
port: None,
kids: false,
adult: false,
};
assert!(add_in(temp.path(), &args, &config).is_err());
}
#[test]
fn default_set_show_and_clear_round_trip_the_marker() {
let temp = TempDir::new().unwrap();
let dir = temp.path().join("profiles");
fs::create_dir_all(&dir).unwrap();
fs::write(config_path_in(&dir, "timmy"), "").unwrap();
default_cmd_in(
&dir,
ProfileDefaultArgs {
name: Some("timmy".to_string()),
clear: false,
},
)
.unwrap();
assert_eq!(Some("timmy".to_string()), read_default_in(&dir).unwrap());
default_cmd_in(
&dir,
ProfileDefaultArgs {
name: None,
clear: false,
},
)
.unwrap();
default_cmd_in(
&dir,
ProfileDefaultArgs {
name: None,
clear: true,
},
)
.unwrap();
assert_eq!(None, read_default_in(&dir).unwrap());
}
#[test]
fn default_resolution_handles_absent_valid_and_missing_profiles() {
let temp = TempDir::new().unwrap();
let dir = temp.path().join("profiles");
assert_eq!(None, resolve_default_in(&dir).unwrap());
fs::create_dir_all(&dir).unwrap();
fs::write(config_path_in(&dir, "timmy"), "").unwrap();
fs::write(default_marker_in(&dir), "timmy\n").unwrap();
assert_eq!(Some("timmy".to_string()), resolve_default_in(&dir).unwrap());
fs::remove_file(config_path_in(&dir, "timmy")).unwrap();
assert!(resolve_default_in(&dir).is_err());
}
#[test]
fn launch_all_plan_reads_each_profile_name_and_port_in_order() {
let temp = TempDir::new().unwrap();
let dir = temp.path().join("profiles");
fs::create_dir_all(&dir).unwrap();
fs::write(
config_path_in(&dir, "timmy"),
"decks_dir = \"/tmp/timmy\"\n[serve]\nport = 7002\n",
)
.unwrap();
fs::write(
config_path_in(&dir, "anna"),
"decks_dir = \"/tmp/anna\"\n[serve]\nport = 7001\n",
)
.unwrap();
fs::write(default_marker_in(&dir), "timmy\n").unwrap();
assert_eq!(
vec![("anna".to_string(), 7001), ("timmy".to_string(), 7002)],
plan_launch_all(&dir).unwrap()
);
}
#[test]
fn launch_all_forwards_log_targets_before_the_profile_subcommand() {
assert_eq!(
vec!["--log", "http,select", "profile", "timmy"],
launch_all_child_args(
"timmy",
&[alix::log::Target::Http, alix::log::Target::Select]
)
);
assert_eq!(
vec!["profile", "timmy"],
launch_all_child_args("timmy", &[])
);
}
#[cfg(unix)]
#[test]
fn a_finished_child_reports_only_what_the_operator_needs() {
use std::os::unix::process::ExitStatusExt;
let ok = std::process::ExitStatus::from_raw(0);
assert_eq!(None, child_outcome("work", Ok(ok)));
let bad = std::process::ExitStatus::from_raw(1 << 8);
let message = child_outcome("work", Ok(bad)).expect("a failed child is reported");
assert!(message.contains("work"), "{message}");
assert!(message.contains("exited with"), "{message}");
let broken = child_outcome("work", Err(std::io::Error::other("no child")))
.expect("a broken wait is reported");
assert!(broken.contains("could not wait"), "{broken}");
assert!(broken.contains("no child"), "{broken}");
}
}