initial skeleton

This commit is contained in:
stoorps 2026-03-19 18:46:50 +00:00
parent dc79fa2448
commit 71f89dde9c
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
60 changed files with 3480 additions and 0 deletions

21
crates/aim-cli/Cargo.toml Normal file
View 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

View 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,
}

View file

@ -0,0 +1 @@
pub mod args;

108
crates/aim-cli/src/lib.rs Normal file
View 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)
}
}

View 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);
}
}
}

View file

@ -0,0 +1,2 @@
pub mod prompt;
pub mod render;

View file

@ -0,0 +1,3 @@
use aim_core::app::interaction::InteractionRequest;
pub fn handle_interaction(_request: &InteractionRequest) {}

View 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()
}

View 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"));
}

View 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();
}

View 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(
&registry_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", &registry_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(
&registry_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", &registry_path)
.assert()
.success()
.stdout(contains("removed: Bat"));
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(!contents.contains("stable_id = \"bat\""));
}

View 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"));
}