use anyhow::{Context, anyhow};
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::Path;
use crate::context::CommandContext;
use crate::usage_stats::EnvStats;
use enwiro_sdk::client::{CachedRecipe, EnvScores};
#[derive(clap::Args)]
#[command(
author,
version,
about = "list all existing environments as well as recipes to create environments"
)]
pub struct ListAllArgs {
#[arg(long)]
pub json: bool,
}
pub fn list_all<W: Write>(context: &mut CommandContext<W>, json: bool) -> anyhow::Result<()> {
let mut envs: Vec<_> = context.get_all_environments()?.into_values().collect();
let mut meta_map: HashMap<String, EnvStats> = HashMap::new();
for env in &envs {
let env_dir = Path::new(&context.config.workspaces_directory).join(&env.name);
let meta = crate::usage_stats::load_env_meta(&env_dir);
if !meta.signals.activation_buffer.is_empty() || meta.description.is_some() {
meta_map.insert(env.name.clone(), meta);
}
}
let legacy_stats = crate::usage_stats::load_stats_default();
for env in &envs {
if !meta_map.contains_key(&env.name)
&& let Some(s) = legacy_stats.envs.get(&env.name)
{
meta_map.insert(env.name.clone(), s.clone());
}
}
for env in &envs {
meta_map.entry(env.name.clone()).or_default();
}
let now = crate::usage_stats::now_timestamp();
let percentile_map = crate::usage_stats::launcher_score(&meta_map, now);
let slot_map = crate::usage_stats::slot_scores(&meta_map, now);
envs.sort_by(|a, b| {
let score_a = percentile_map.get(&a.name).copied().unwrap_or(0.0);
let score_b = percentile_map.get(&b.name).copied().unwrap_or(0.0);
score_b
.partial_cmp(&score_a)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.name.cmp(&b.name))
});
for env in &envs {
if json {
let launcher = percentile_map.get(&env.name).copied().unwrap_or(0.0);
let slot = slot_map.get(&env.name).copied().unwrap_or(0.0);
let cached = CachedRecipe {
cookbook: "_".to_string(),
name: env.name.clone(),
description: meta_map.get(&env.name).and_then(|s| s.description.clone()),
sort_order: 0,
scores: Some(EnvScores { launcher, slot }),
};
let line = serde_json::to_string(&cached).unwrap();
writeln!(context.writer, "{}", line).context("Could not write to output")?;
} else {
let line = match meta_map
.get(&env.name)
.and_then(|s| s.description.as_deref())
{
Some(desc) => format!("_: {}\t{}", env.name, desc),
None => format!("_: {}", env.name),
};
writeln!(context.writer, "{}", line).context("Could not write to output")?;
}
}
let env_names: HashSet<&str> = envs.iter().map(|e| e.name.as_str()).collect();
let cache = match &context.cache_dir {
Some(dir) => enwiro_daemon::DaemonCache::with_runtime_dir(dir.clone()),
None => enwiro_daemon::DaemonCache::open()?,
};
let recipes = cache
.read_recipes()
.context("Could not read the daemon cache")?
.ok_or_else(|| {
anyhow!(
"Daemon cache is not available. \
Check: systemctl --user status enwiro-daemon.service"
)
})?;
for line in recipes.lines() {
if line.is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<CachedRecipe>(line) {
if env_names.contains(entry.name.as_str()) {
continue;
}
if json {
writeln!(context.writer, "{}", line).context("Could not write recipe to output")?;
} else {
let formatted = match &entry.description {
Some(desc) => format!("{}: {}\t{}", entry.cookbook, entry.name, desc),
None => format!("{}: {}", entry.cookbook, entry.name),
};
writeln!(context.writer, "{}", formatted)
.context("Could not write recipe to output")?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use crate::test_utils::test_utilities::{
AdapterLog, FakeContext, NotificationLog, context_object,
};
use enwiro_daemon::meta::UserIntentSignals;
fn parse_json_entries(output: &str) -> Vec<CachedRecipe> {
output
.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[rstest]
fn test_list_all_shows_environments_and_recipes(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("my-env");
context_object.write_cache_entries(&[("git", "repo-a", None), ("git", "repo-b", None)]);
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
assert!(output.contains("_: my-env"));
assert!(output.contains("git: repo-a"));
assert!(output.contains("git: repo-b"));
}
#[rstest]
fn test_list_all_excludes_recipes_that_match_existing_environments(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("repo-a");
context_object.write_cache_entries(&[("git", "repo-a", None), ("git", "repo-b", None)]);
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
assert!(output.contains("_: repo-a"), "Environment should be listed");
assert!(
!output.contains("git: repo-a"),
"Recipe matching an existing environment should be excluded"
);
assert!(
output.contains("git: repo-b"),
"Recipe without a matching environment should still be listed"
);
}
#[rstest]
fn test_list_all_excludes_recipes_with_descriptions_that_match_existing_environments(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("repo#42");
context_object.write_cache_entries(&[
("github", "repo#42", Some("Fix auth bug")),
("github", "repo#99", Some("Add feature")),
]);
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
assert!(
!output.contains("github: repo#42"),
"Recipe with description matching an existing environment should be excluded"
);
assert!(
output.contains("github: repo#99\tAdd feature"),
"Non-matching recipe with description should still be listed"
);
}
#[rstest]
fn test_list_all_with_no_recipes_in_cache(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("env-a");
context_object.create_mock_environment("env-b");
context_object.write_cache_entries(&[]);
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
assert!(output.contains("_: env-a"));
assert!(output.contains("_: env-b"));
assert!(!output.contains("git:"));
}
#[rstest]
fn test_list_all_with_no_environments_but_has_recipes(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.write_cache_entries(&[("git", "some-repo", None)]);
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
assert!(output.contains("git: some-repo"));
assert!(!output.contains("_:"));
}
#[rstest]
fn test_list_all_with_multiple_cookbooks(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.write_cache_entries(&[
("git", "repo-a", None),
("npm", "pkg-x", None),
("npm", "pkg-y", None),
]);
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
assert!(output.contains("git: repo-a"));
assert!(output.contains("npm: pkg-x"));
assert!(output.contains("npm: pkg-y"));
}
#[rstest]
fn test_list_all_reads_from_cache_when_available(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
let cache_dir = context_object.cache_dir.clone().unwrap();
std::fs::create_dir_all(&cache_dir).unwrap();
std::fs::write(
cache_dir.join("recipes.cache"),
"{\"cookbook\":\"git\",\"name\":\"cached-repo\"}\n",
)
.unwrap();
context_object.cookbooks = vec![];
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
assert!(
output.contains("git: cached-repo"),
"Should read from cache, got: {}",
output
);
}
#[rstest]
fn test_list_all_sorts_environments_by_frecency(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("rarely-used");
context_object.create_mock_environment("often-used");
context_object.create_mock_environment("never-used");
context_object.write_cache_entries(&[]);
let now = crate::usage_stats::now_timestamp();
let often_meta = crate::usage_stats::EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0); 10],
..Default::default()
},
..Default::default()
};
let rarely_meta = crate::usage_stats::EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now - 700_000, 1.0)],
..Default::default()
},
..Default::default()
};
let often_dir = temp_dir.path().join("often-used");
let rarely_dir = temp_dir.path().join("rarely-used");
std::fs::write(
often_dir.join("meta.json"),
serde_json::to_string(&often_meta).unwrap(),
)
.unwrap();
std::fs::write(
rarely_dir.join("meta.json"),
serde_json::to_string(&rarely_meta).unwrap(),
)
.unwrap();
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
let env_lines: Vec<&str> = output.lines().filter(|l| l.starts_with("_: ")).collect();
assert_eq!(env_lines[0], "_: often-used");
assert_eq!(env_lines[1], "_: rarely-used");
assert_eq!(env_lines[2], "_: never-used");
}
#[rstest]
fn test_list_all_orders_environments_by_launcher_percentile_score(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("low-activity");
context_object.create_mock_environment("mid-activity");
context_object.create_mock_environment("high-activity");
context_object.write_cache_entries(&[]);
let now = crate::usage_stats::now_timestamp();
let high_meta = crate::usage_stats::EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0); 5],
..Default::default()
},
..Default::default()
};
let mid_meta = crate::usage_stats::EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now - 48 * 3600, 1.0)],
..Default::default()
},
..Default::default()
};
std::fs::write(
temp_dir.path().join("high-activity").join("meta.json"),
serde_json::to_string(&high_meta).unwrap(),
)
.unwrap();
std::fs::write(
temp_dir.path().join("mid-activity").join("meta.json"),
serde_json::to_string(&mid_meta).unwrap(),
)
.unwrap();
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
let env_lines: Vec<&str> = output.lines().filter(|l| l.starts_with("_: ")).collect();
assert_eq!(
env_lines.len(),
3,
"expected 3 env lines, got: {:?}",
env_lines
);
assert_eq!(
env_lines[0], "_: high-activity",
"highest percentile rank must be first"
);
assert_eq!(
env_lines[1], "_: mid-activity",
"middle percentile rank must be second"
);
assert_eq!(
env_lines[2], "_: low-activity",
"lowest percentile rank (no activations) must be last"
);
}
#[rstest]
fn test_list_all_uses_launcher_score_for_ordering(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
use std::collections::HashMap;
let (temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("alpha");
context_object.create_mock_environment("beta");
context_object.write_cache_entries(&[]);
let now = crate::usage_stats::now_timestamp();
let beta_meta = crate::usage_stats::EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
..Default::default()
},
..Default::default()
};
let alpha_meta = crate::usage_stats::EnvStats::default();
std::fs::write(
temp_dir.path().join("beta").join("meta.json"),
serde_json::to_string(&beta_meta).unwrap(),
)
.unwrap();
let mut meta_map: HashMap<String, crate::usage_stats::EnvStats> = HashMap::new();
meta_map.insert("alpha".to_string(), alpha_meta.clone());
meta_map.insert("beta".to_string(), beta_meta.clone());
let scores = crate::usage_stats::launcher_score(&meta_map, now);
assert!(
scores["beta"] > scores["alpha"],
"launcher_score must rank beta higher than alpha; beta={}, alpha={}",
scores["beta"],
scores["alpha"]
);
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
let env_lines: Vec<&str> = output.lines().filter(|l| l.starts_with("_: ")).collect();
assert_eq!(
env_lines.len(),
2,
"expected 2 env lines, got: {:?}",
env_lines
);
assert_eq!(
env_lines[0], "_: beta",
"list_all must put the environment with the higher launcher_score first"
);
assert_eq!(
env_lines[1], "_: alpha",
"list_all must put the environment with the lower launcher_score second"
);
}
#[rstest]
fn test_list_all_shows_description_for_environments(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("owner-repo#42");
context_object.write_cache_entries(&[]);
let now = crate::usage_stats::now_timestamp();
let meta = crate::usage_stats::EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0)],
..Default::default()
},
description: Some("Fix auth bug".to_string()),
cookbook: Some("github".to_string()),
recipe: Some("owner/repo#42".to_string()),
};
let env_dir = temp_dir.path().join("owner-repo#42");
std::fs::write(
env_dir.join("meta.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
assert!(
output.contains("_: owner-repo#42\tFix auth bug"),
"Expected description in environment listing, got: {}",
output
);
}
#[rstest]
fn test_list_all_preserves_cache_order(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.write_cache_entries(&[
("git", "my-repo", None),
("chezmoi", "dotfiles", None),
("github", "repo#1", None),
]);
list_all(&mut context_object, false).unwrap();
let output = context_object.get_output();
let recipe_lines: Vec<&str> = output.lines().filter(|l| !l.starts_with("_: ")).collect();
assert_eq!(recipe_lines[0], "git: my-repo");
assert_eq!(recipe_lines[1], "chezmoi: dotfiles");
assert_eq!(recipe_lines[2], "github: repo#1");
}
#[rstest]
fn test_list_all_errors_when_cache_unavailable(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
let result = list_all(&mut context_object, false);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("daemon"),
"Error should point at the daemon, got: {err}"
);
}
#[rstest]
fn test_list_all_json_flag_outputs_jsonl(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("my-env");
context_object.write_cache_entries(&[("git", "repo-a", None)]);
list_all(&mut context_object, true).unwrap();
let entries = parse_json_entries(&context_object.get_output());
assert!(
entries
.iter()
.any(|e| e.cookbook == "_" && e.name == "my-env")
);
assert!(
entries
.iter()
.any(|e| e.cookbook == "git" && e.name == "repo-a")
);
}
#[rstest]
fn test_list_all_json_env_entry_has_scores_object(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("my-env");
context_object.write_cache_entries(&[]);
list_all(&mut context_object, true).unwrap();
let output = context_object.get_output();
let entries: Vec<serde_json::Value> = output
.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let env_entry = entries
.iter()
.find(|e| e["cookbook"] == "_" && e["name"] == "my-env")
.expect("expected an entry for my-env with cookbook=_");
let scores = env_entry
.get("scores")
.expect("env entry must have a 'scores' field");
assert!(
scores.get("launcher").is_some(),
"scores must have a 'launcher' field, got: {scores}"
);
assert!(
scores["launcher"].is_f64() || scores["launcher"].is_number(),
"scores.launcher must be a number, got: {}",
scores["launcher"]
);
assert!(
scores.get("slot").is_some(),
"scores must have a 'slot' field, got: {scores}"
);
assert!(
scores["slot"].is_f64() || scores["slot"].is_number(),
"scores.slot must be a number, got: {}",
scores["slot"]
);
}
#[rstest]
fn test_list_all_json_recipe_entry_has_no_scores_field(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.write_cache_entries(&[("git", "repo-a", None)]);
list_all(&mut context_object, true).unwrap();
let output = context_object.get_output();
let entries: Vec<serde_json::Value> = output
.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let recipe_entry = entries
.iter()
.find(|e| e["cookbook"] == "git" && e["name"] == "repo-a")
.expect("expected a recipe entry for git: repo-a");
assert!(
recipe_entry.get("scores").is_none(),
"recipe entry must NOT have a 'scores' field, got: {recipe_entry}"
);
}
#[rstest]
fn test_list_all_json_higher_frecency_env_has_higher_scores(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (temp_dir, mut context_object, _, _) = context_object;
context_object.create_mock_environment("often-used");
context_object.create_mock_environment("never-used");
context_object.write_cache_entries(&[]);
let now = crate::usage_stats::now_timestamp();
let often_meta = crate::usage_stats::EnvStats {
signals: UserIntentSignals {
activation_buffer: vec![(now, 1.0); 5],
..Default::default()
},
..Default::default()
};
let often_dir = temp_dir.path().join("often-used");
std::fs::write(
often_dir.join("meta.json"),
serde_json::to_string(&often_meta).unwrap(),
)
.unwrap();
list_all(&mut context_object, true).unwrap();
let output = context_object.get_output();
let entries: Vec<serde_json::Value> = output
.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let often_entry = entries
.iter()
.find(|e| e["cookbook"] == "_" && e["name"] == "often-used")
.expect("expected entry for often-used");
let never_entry = entries
.iter()
.find(|e| e["cookbook"] == "_" && e["name"] == "never-used")
.expect("expected entry for never-used");
let often_launcher = often_entry["scores"]["launcher"]
.as_f64()
.expect("often-used must have scores.launcher as f64");
let never_launcher = never_entry["scores"]["launcher"]
.as_f64()
.expect("never-used must have scores.launcher as f64");
assert!(
often_launcher > never_launcher,
"often-used (launcher={often_launcher}) must outscore never-used (launcher={never_launcher})"
);
let often_slot = often_entry["scores"]["slot"]
.as_f64()
.expect("often-used must have scores.slot as f64");
let never_slot = never_entry["scores"]["slot"]
.as_f64()
.expect("never-used must have scores.slot as f64");
assert!(
often_slot >= never_slot,
"often-used (slot={often_slot}) must not score below never-used (slot={never_slot})"
);
}
}