github source v1

This commit is contained in:
stoorps 2026-03-19 20:14:39 +00:00
parent 71f89dde9c
commit caf870d05e
Signed by: stoorps
SSH key fingerprint: SHA256:AZlPfu9hTu042EGtZElmDQoy+KvMOeShLDan/fYLoNI
50 changed files with 4139 additions and 131 deletions

View file

@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies]
clap.workspace = true
dialoguer.workspace = true
aim-core = { path = "../aim-core" }
[dev-dependencies]

View file

@ -4,12 +4,13 @@ pub mod ui;
use std::env;
use std::path::PathBuf;
use aim_core::app::add::build_add_plan;
use aim_core::app::add::{AddPlan, build_add_plan, materialize_app_record};
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::app::AppRecord;
use aim_core::domain::source::SourceRef;
use aim_core::domain::update::UpdatePlan;
use aim_core::domain::update::{ArtifactCandidate, UpdatePlan};
use aim_core::registry::model::Registry;
use aim_core::registry::store::RegistryStore;
@ -45,9 +46,29 @@ pub fn dispatch(cli: Cli) -> Result<DispatchResult, DispatchError> {
}
if let Some(query) = cli.query {
return Ok(DispatchResult::AddPlan(
build_add_plan(&query)?.resolution.source,
));
let mut plan = build_add_plan(&query)?;
if !plan.interactions.is_empty() {
match ui::prompt::resolve_add_plan_interactions(plan.clone())? {
Some(resolved) => {
plan = resolved;
}
None => return Ok(DispatchResult::PendingAdd(plan)),
}
}
let record = materialize_app_record(&query, &plan)?;
let mut updated_apps = registry.apps.clone();
upsert_app_record(&mut updated_apps, record.clone());
store.save(&Registry {
version: registry.version,
apps: updated_apps,
})?;
return Ok(DispatchResult::Added(AddedApp {
record,
selected_artifact: plan.selected_artifact,
source: plan.resolution.source,
}));
}
Ok(DispatchResult::Noop)
@ -68,16 +89,26 @@ fn registry_path() -> PathBuf {
#[derive(Debug, Eq, PartialEq)]
pub enum DispatchResult {
AddPlan(SourceRef),
Added(AddedApp),
List(Vec<ListRow>),
PendingAdd(AddPlan),
Removed(String),
UpdatePlan(UpdatePlan),
Noop,
}
#[derive(Debug, Eq, PartialEq)]
pub struct AddedApp {
pub record: AppRecord,
pub selected_artifact: ArtifactCandidate,
pub source: SourceRef,
}
#[derive(Debug)]
pub enum DispatchError {
AddPlan(aim_core::app::add::BuildAddPlanError),
AddRecord(aim_core::app::add::MaterializeAddRecordError),
Prompt(ui::prompt::PromptError),
RemovePlan(aim_core::app::remove::ResolveRegisteredAppError),
Registry(aim_core::registry::store::RegistryStoreError),
UpdatePlan(aim_core::app::update::BuildUpdatePlanError),
@ -89,6 +120,18 @@ impl From<aim_core::app::add::BuildAddPlanError> for DispatchError {
}
}
impl From<aim_core::app::add::MaterializeAddRecordError> for DispatchError {
fn from(value: aim_core::app::add::MaterializeAddRecordError) -> Self {
Self::AddRecord(value)
}
}
impl From<ui::prompt::PromptError> for DispatchError {
fn from(value: ui::prompt::PromptError) -> Self {
Self::Prompt(value)
}
}
impl From<aim_core::app::update::BuildUpdatePlanError> for DispatchError {
fn from(value: aim_core::app::update::BuildUpdatePlanError) -> Self {
Self::UpdatePlan(value)
@ -106,3 +149,15 @@ impl From<aim_core::registry::store::RegistryStoreError> for DispatchError {
Self::Registry(value)
}
}
fn upsert_app_record(apps: &mut Vec<AppRecord>, record: AppRecord) {
if let Some(existing) = apps
.iter_mut()
.find(|item| item.stable_id == record.stable_id)
{
*existing = record;
return;
}
apps.push(record);
}

View file

@ -1,3 +1,113 @@
use aim_core::app::interaction::InteractionRequest;
use std::env;
use std::io::IsTerminal;
pub fn handle_interaction(_request: &InteractionRequest) {}
use aim_core::app::add::{AddPlan, prefer_latest_tracking};
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
use dialoguer::{Select, theme::ColorfulTheme};
const TRACKING_PREFERENCE_ENV: &str = "AIM_TRACKING_PREFERENCE";
pub fn render_interaction(request: &InteractionRequest) -> String {
match &request.kind {
InteractionKind::SelectRegisteredApp { query, matches } => format!(
"multiple installed apps match '{query}': {}",
matches.join(", ")
),
InteractionKind::ChooseTrackingPreference {
requested_version,
latest_version,
} => format!(
"tracking preference required: requested {requested_version}, latest available {latest_version}",
),
InteractionKind::SelectArtifact { candidates } => {
format!("artifact selection required: {}", candidates.join(", "))
}
}
}
pub fn render_interactions(requests: &[InteractionRequest]) -> String {
requests
.iter()
.map(render_interaction)
.collect::<Vec<_>>()
.join("\n")
}
pub fn resolve_add_plan_interactions(plan: AddPlan) -> Result<Option<AddPlan>, PromptError> {
let mut resolved = plan;
for request in resolved.interactions.clone() {
match &request.kind {
InteractionKind::ChooseTrackingPreference {
requested_version,
latest_version,
} => match resolve_tracking_preference(requested_version, latest_version)? {
Some(TrackingPreference::Requested) => {
resolved
.interactions
.retain(|item| item.key != "tracking-preference");
}
Some(TrackingPreference::Latest) => {
resolved = prefer_latest_tracking(resolved);
}
None => return Ok(None),
},
InteractionKind::SelectRegisteredApp { .. }
| InteractionKind::SelectArtifact { .. } => {
return Ok(None);
}
}
}
Ok(Some(resolved))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TrackingPreference {
Requested,
Latest,
}
#[derive(Debug)]
pub enum PromptError {
InvalidTrackingPreference(String),
Dialoguer(dialoguer::Error),
}
impl From<dialoguer::Error> for PromptError {
fn from(value: dialoguer::Error) -> Self {
Self::Dialoguer(value)
}
}
fn resolve_tracking_preference(
requested_version: &str,
latest_version: &str,
) -> Result<Option<TrackingPreference>, PromptError> {
if let Ok(value) = env::var(TRACKING_PREFERENCE_ENV) {
return match value.trim().to_ascii_lowercase().as_str() {
"requested" | "current" => Ok(Some(TrackingPreference::Requested)),
"latest" => Ok(Some(TrackingPreference::Latest)),
other => Err(PromptError::InvalidTrackingPreference(other.to_owned())),
};
}
if !std::io::stdin().is_terminal() {
return Ok(None);
}
let options = [
format!("Keep tracking the requested release lineage ({requested_version})"),
format!("Track the latest release after install ({latest_version})"),
];
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Choose update tracking behavior")
.items(options)
.default(1)
.interact()?;
Ok(Some(match selection {
0 => TrackingPreference::Requested,
_ => TrackingPreference::Latest,
}))
}

View file

@ -1,4 +1,4 @@
use aim_core::domain::source::SourceRef;
use aim_core::app::add::AddPlan;
use crate::DispatchResult;
@ -8,8 +8,9 @@ pub fn render_update_summary(total: usize, selected: usize, failed: usize) -> St
pub fn render_dispatch_result(result: &DispatchResult) -> String {
match result {
DispatchResult::AddPlan(source) => render_add_plan(source),
DispatchResult::Added(added) => render_added_app(added),
DispatchResult::List(rows) => render_list(rows),
DispatchResult::PendingAdd(plan) => render_pending_add(plan),
DispatchResult::Removed(display_name) => format!("removed: {display_name}"),
DispatchResult::UpdatePlan(plan) => {
render_update_summary(plan.items.len(), plan.items.len(), 0)
@ -18,11 +19,26 @@ pub fn render_dispatch_result(result: &DispatchResult) -> String {
}
}
fn render_add_plan(source: &SourceRef) -> String {
fn render_added_app(added: &crate::AddedApp) -> String {
format!(
"resolved source: {} {}",
source.kind.as_str(),
source.locator
"tracked app: {} ({})\nsource: {} {}\nselected artifact: {} [{}]",
added.record.display_name,
added.record.stable_id,
added.source.kind.as_str(),
added.source.locator,
added.selected_artifact.url,
added.selected_artifact.selection_reason,
)
}
fn render_pending_add(plan: &AddPlan) -> String {
let prompts = crate::ui::prompt::render_interactions(&plan.interactions);
format!(
"resolved source: {} {}\nselected artifact: {} [{}]\n{prompts}",
plan.resolution.source.kind.as_str(),
plan.resolution.source.locator,
plan.selected_artifact.url,
plan.selected_artifact.selection_reason,
)
}

View file

@ -2,6 +2,8 @@ use assert_cmd::Command;
use predicates::str::contains;
use tempfile::tempdir;
const FIXTURE_MODE_ENV: &str = "AIM_GITHUB_FIXTURE_MODE";
#[test]
fn list_command_runs_without_registry_entries() {
let mut cmd = Command::cargo_bin("aim").unwrap();
@ -52,3 +54,58 @@ fn remove_command_removes_registered_app_from_registry_file() {
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(!contents.contains("stable_id = \"bat\""));
}
#[test]
fn query_command_registers_unambiguous_app_in_registry_file() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("sharkdp/bat")
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("tracked app: bat (sharkdp-bat)"));
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(contents.contains("stable_id = \"sharkdp-bat\""));
assert!(contents.contains("source_input = \"sharkdp/bat\""));
}
#[test]
fn old_release_query_renders_tracking_prompt_without_writing_registry() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage")
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.assert()
.success()
.stdout(contains("tracking preference required"))
.stdout(contains("v0.0.11"))
.stdout(contains("v0.0.12"));
assert!(!registry_path.exists());
}
#[test]
fn old_release_query_can_track_latest_and_register_app() {
let dir = tempdir().unwrap();
let registry_path = dir.path().join("registry.toml");
let mut cmd = Command::cargo_bin("aim").unwrap();
cmd.arg("https://github.com/pingdotgg/t3code/releases/download/v0.0.11/T3-Code-0.0.11-x86_64.AppImage")
.env("AIM_REGISTRY_PATH", &registry_path)
.env(FIXTURE_MODE_ENV, "1")
.env("AIM_TRACKING_PREFERENCE", "latest")
.assert()
.success()
.stdout(contains("tracked app: t3code (pingdotgg-t3code)"));
let contents = std::fs::read_to_string(&registry_path).unwrap();
assert!(contents.contains("stable_id = \"pingdotgg-t3code\""));
assert!(contents.contains("locator = \"pingdotgg/t3code\""));
}

View file

@ -1,7 +1,24 @@
use aim_cli::ui::prompt::render_interaction;
use aim_cli::ui::render::render_update_summary;
use aim_core::app::interaction::{InteractionKind, InteractionRequest};
#[test]
fn update_summary_mentions_selected_count() {
let output = render_update_summary(3, 2, 1);
assert!(output.contains("selected: 2"));
}
#[test]
fn tracking_prompt_mentions_requested_and_latest_versions() {
let output = render_interaction(&InteractionRequest {
key: "tracking-preference".to_owned(),
kind: InteractionKind::ChooseTrackingPreference {
requested_version: "v0.0.11".to_owned(),
latest_version: "v0.0.12".to_owned(),
},
});
assert!(output.contains("tracking preference required"));
assert!(output.contains("v0.0.11"));
assert!(output.contains("v0.0.12"));
}