initial skeleton
This commit is contained in:
parent
dc79fa2448
commit
71f89dde9c
60 changed files with 3480 additions and 0 deletions
21
crates/aim-cli/Cargo.toml
Normal file
21
crates/aim-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
[package]
|
||||
name = "aim-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "aim"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
aim-core = { path = "../aim-core" }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd.workspace = true
|
||||
predicates = "3.1.3"
|
||||
tempfile.workspace = true
|
||||
31
crates/aim-cli/src/cli/args.rs
Normal file
31
crates/aim-cli/src/cli/args.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use clap::Parser;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "aim")]
|
||||
#[command(about = "AppImage Manager")]
|
||||
pub struct Cli {
|
||||
#[arg(global = true, long = "system", conflicts_with = "user")]
|
||||
pub system: bool,
|
||||
|
||||
#[arg(global = true, long = "user", conflicts_with = "system")]
|
||||
pub user: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub fn is_review_update_flow(&self) -> bool {
|
||||
matches!(self.command, Some(Command::Update))
|
||||
|| (self.command.is_none() && self.query.is_none())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
pub enum Command {
|
||||
Remove { query: String },
|
||||
List,
|
||||
Update,
|
||||
}
|
||||
1
crates/aim-cli/src/cli/mod.rs
Normal file
1
crates/aim-cli/src/cli/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod args;
|
||||
108
crates/aim-cli/src/lib.rs
Normal file
108
crates/aim-cli/src/lib.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
pub mod cli;
|
||||
pub mod ui;
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aim_core::app::add::build_add_plan;
|
||||
use aim_core::app::list::{ListRow, build_list_rows};
|
||||
use aim_core::app::remove::remove_registered_app;
|
||||
use aim_core::app::update::build_update_plan;
|
||||
use aim_core::domain::source::SourceRef;
|
||||
use aim_core::domain::update::UpdatePlan;
|
||||
use aim_core::registry::model::Registry;
|
||||
use aim_core::registry::store::RegistryStore;
|
||||
|
||||
pub use cli::args::Cli;
|
||||
|
||||
pub fn parse() -> Cli {
|
||||
<Cli as clap::Parser>::parse()
|
||||
}
|
||||
|
||||
pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
|
||||
let registry_path = registry_path();
|
||||
let store = RegistryStore::new(registry_path);
|
||||
let registry = store.load()?;
|
||||
let apps = registry.apps.clone();
|
||||
|
||||
if cli.is_review_update_flow() {
|
||||
return Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?));
|
||||
}
|
||||
|
||||
if let Some(command) = cli.command {
|
||||
return match command {
|
||||
cli::args::Command::List => Ok(DispatchResult::List(build_list_rows(&apps))),
|
||||
cli::args::Command::Remove { query } => {
|
||||
let removal = remove_registered_app(&query, &apps)?;
|
||||
store.save(&Registry {
|
||||
version: registry.version,
|
||||
apps: removal.remaining_apps,
|
||||
})?;
|
||||
Ok(DispatchResult::Removed(removal.removed.display_name))
|
||||
}
|
||||
cli::args::Command::Update => Ok(DispatchResult::UpdatePlan(build_update_plan(&apps)?)),
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(query) = cli.query {
|
||||
return Ok(DispatchResult::AddPlan(
|
||||
build_add_plan(&query)?.resolution.source,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(DispatchResult::Noop)
|
||||
}
|
||||
|
||||
pub fn render(result: &DispatchResult) -> String {
|
||||
ui::render::render_dispatch_result(result)
|
||||
}
|
||||
|
||||
fn registry_path() -> PathBuf {
|
||||
if let Some(path) = env::var_os("AIM_REGISTRY_PATH") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
|
||||
let home = env::var_os("HOME").unwrap_or_else(|| ".".into());
|
||||
PathBuf::from(home).join(".local/share/aim/registry.toml")
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub enum DispatchResult {
|
||||
AddPlan(SourceRef),
|
||||
List(Vec<ListRow>),
|
||||
Removed(String),
|
||||
UpdatePlan(UpdatePlan),
|
||||
Noop,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DispatchError {
|
||||
AddPlan(aim_core::app::add::BuildAddPlanError),
|
||||
RemovePlan(aim_core::app::remove::ResolveRegisteredAppError),
|
||||
Registry(aim_core::registry::store::RegistryStoreError),
|
||||
UpdatePlan(aim_core::app::update::BuildUpdatePlanError),
|
||||
}
|
||||
|
||||
impl From<aim_core::app::add::BuildAddPlanError> for DispatchError {
|
||||
fn from(value: aim_core::app::add::BuildAddPlanError) -> Self {
|
||||
Self::AddPlan(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<aim_core::app::update::BuildUpdatePlanError> for DispatchError {
|
||||
fn from(value: aim_core::app::update::BuildUpdatePlanError) -> Self {
|
||||
Self::UpdatePlan(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<aim_core::app::remove::ResolveRegisteredAppError> for DispatchError {
|
||||
fn from(value: aim_core::app::remove::ResolveRegisteredAppError) -> Self {
|
||||
Self::RemovePlan(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<aim_core::registry::store::RegistryStoreError> for DispatchError {
|
||||
fn from(value: aim_core::registry::store::RegistryStoreError) -> Self {
|
||||
Self::Registry(value)
|
||||
}
|
||||
}
|
||||
15
crates/aim-cli/src/main.rs
Normal file
15
crates/aim-cli/src/main.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
fn main() {
|
||||
let cli = aim_cli::parse();
|
||||
match aim_cli::dispatch(cli) {
|
||||
Ok(result) => {
|
||||
let output = aim_cli::render(&result);
|
||||
if !output.is_empty() {
|
||||
println!("{output}");
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("{error:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
crates/aim-cli/src/ui/mod.rs
Normal file
2
crates/aim-cli/src/ui/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod prompt;
|
||||
pub mod render;
|
||||
3
crates/aim-cli/src/ui/prompt.rs
Normal file
3
crates/aim-cli/src/ui/prompt.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
use aim_core::app::interaction::InteractionRequest;
|
||||
|
||||
pub fn handle_interaction(_request: &InteractionRequest) {}
|
||||
39
crates/aim-cli/src/ui/render.rs
Normal file
39
crates/aim-cli/src/ui/render.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
use aim_core::domain::source::SourceRef;
|
||||
|
||||
use crate::DispatchResult;
|
||||
|
||||
pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> String {
|
||||
format!("updates found: {total}, selected: {selected}, failed: {failed}",)
|
||||
}
|
||||
|
||||
pub fn render_dispatch_result(result: &DispatchResult) -> String {
|
||||
match result {
|
||||
DispatchResult::AddPlan(source) => render_add_plan(source),
|
||||
DispatchResult::List(rows) => render_list(rows),
|
||||
DispatchResult::Removed(display_name) => format!("removed: {display_name}"),
|
||||
DispatchResult::UpdatePlan(plan) => {
|
||||
render_update_summary(plan.items.len(), plan.items.len(), 0)
|
||||
}
|
||||
DispatchResult::Noop => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_add_plan(source: &SourceRef) -> String {
|
||||
format!(
|
||||
"resolved source: {} {}",
|
||||
source.kind.as_str(),
|
||||
source.locator
|
||||
)
|
||||
}
|
||||
|
||||
fn render_list(rows: &[aim_core::app::list::ListRow]) -> String {
|
||||
if rows.is_empty() {
|
||||
return "installed apps: none".to_owned();
|
||||
}
|
||||
|
||||
let mut output = String::from("installed apps:\n");
|
||||
for row in rows {
|
||||
output.push_str(&format!("- {} ({})\n", row.display_name, row.stable_id));
|
||||
}
|
||||
output.trim_end().to_owned()
|
||||
}
|
||||
13
crates/aim-cli/tests/cli_commands.rs
Normal file
13
crates/aim-cli/tests/cli_commands.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
use assert_cmd::Command;
|
||||
use predicates::str::contains;
|
||||
|
||||
#[test]
|
||||
fn help_lists_expected_commands() {
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
cmd.arg("--help")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("remove"))
|
||||
.stdout(contains("list"))
|
||||
.stdout(contains("update"));
|
||||
}
|
||||
7
crates/aim-cli/tests/cli_smoke.rs
Normal file
7
crates/aim-cli/tests/cli_smoke.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
use assert_cmd::Command;
|
||||
|
||||
#[test]
|
||||
fn cli_shows_help() {
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
cmd.arg("--help").assert().success();
|
||||
}
|
||||
54
crates/aim-cli/tests/end_to_end_cli.rs
Normal file
54
crates/aim-cli/tests/end_to_end_cli.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
use assert_cmd::Command;
|
||||
use predicates::str::contains;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn list_command_runs_without_registry_entries() {
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("list")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("installed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_command_reads_registered_apps_from_registry_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
std::fs::write(
|
||||
®istry_path,
|
||||
"version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.arg("list")
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Bat (bat)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_command_removes_registered_app_from_registry_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let registry_path = dir.path().join("registry.toml");
|
||||
std::fs::write(
|
||||
®istry_path,
|
||||
"version = 1\n[[apps]]\nstable_id = \"bat\"\ndisplay_name = \"Bat\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("aim").unwrap();
|
||||
|
||||
cmd.args(["remove", "bat"])
|
||||
.env("AIM_REGISTRY_PATH", ®istry_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("removed: Bat"));
|
||||
|
||||
let contents = std::fs::read_to_string(®istry_path).unwrap();
|
||||
assert!(!contents.contains("stable_id = \"bat\""));
|
||||
}
|
||||
7
crates/aim-cli/tests/ui_summary.rs
Normal file
7
crates/aim-cli/tests/ui_summary.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
use aim_cli::ui::render::render_update_summary;
|
||||
|
||||
#[test]
|
||||
fn update_summary_mentions_selected_count() {
|
||||
let output = render_update_summary(3, 2, 1);
|
||||
assert!(output.contains("selected: 2"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue