use anyhow::Context;
use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use crate::context::CommandContext;
use crate::daemon;
use crate::usage_stats::EnvStats;
#[derive(clap::Args)]
#[command(
author,
version,
about = "list all existing environments as well as recipes to create environments"
)]
pub struct ListAllArgs {}
pub fn list_all<W: Write>(context: &mut CommandContext<W>) -> 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.activation_count > 0 || 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());
}
}
let now = crate::usage_stats::now_timestamp();
envs.sort_by(|a, b| {
let score_a = meta_map
.get(&a.name)
.map(|s| crate::usage_stats::frecency_score(s, now))
.unwrap_or(0.0);
let score_b = meta_map
.get(&b.name)
.map(|s| crate::usage_stats::frecency_score(s, now))
.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 {
let line = match meta_map
.get(&env.name)
.and_then(|s| s.description.as_deref())
{
Some(desc) => format!("_: {}\t{}\n", env.name, desc),
None => format!("_: {}\n", env.name),
};
context
.writer
.write_all(line.as_bytes())
.context("Could not write to output")?;
}
let runtime_dir = match &context.cache_dir {
Some(dir) => dir.clone(),
None => daemon::runtime_dir()?,
};
if context.cache_dir.is_none() {
match daemon::ensure_daemon_running(&runtime_dir) {
Ok(true) => {
tracing::info!("Started background recipe cache daemon");
context
.notifier
.notify_success("Recipe cache daemon started");
}
Ok(false) => {
tracing::debug!("Daemon already running");
}
Err(e) => {
tracing::warn!(error = %e, "Could not ensure daemon is running");
}
}
}
match daemon::read_cached_recipes(&runtime_dir) {
Ok(Some(cached)) => {
let _ = daemon::touch_heartbeat(&runtime_dir);
context
.writer
.write_all(cached.as_bytes())
.context("Could not write cached recipes to output")?;
}
Ok(None) => {
tracing::debug!("No cache available, falling back to synchronous recipe collection");
let recipes = daemon::collect_all_recipes(&context.cookbooks);
context
.writer
.write_all(recipes.as_bytes())
.context("Could not write recipes to output")?;
}
Err(e) => {
tracing::warn!(error = %e, "Could not read cache, falling back to sync");
let recipes = daemon::collect_all_recipes(&context.cookbooks);
context
.writer
.write_all(recipes.as_bytes())
.context("Could not write recipes to output")?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use crate::test_utils::test_utilities::{
AdapterLog, FakeContext, FakeCookbook, NotificationLog, context_object,
};
#[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.cookbooks = vec![Box::new(FakeCookbook::new(
"git",
vec!["repo-a", "repo-b"],
vec![],
))];
list_all(&mut context_object).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_with_no_cookbooks(
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");
list_all(&mut context_object).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.cookbooks = vec![Box::new(FakeCookbook::new(
"git",
vec!["some-repo"],
vec![],
))];
list_all(&mut context_object).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.cookbooks = vec![
Box::new(FakeCookbook::new("git", vec!["repo-a"], vec![])),
Box::new(FakeCookbook::new("npm", vec!["pkg-x", "pkg-y"], vec![])),
];
list_all(&mut context_object).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();
daemon::write_cache_atomic(&cache_dir, "git: cached-repo\n").unwrap();
context_object.cookbooks = vec![];
list_all(&mut context_object).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");
let now = crate::usage_stats::now_timestamp();
let often_meta = crate::usage_stats::EnvStats {
last_activated: now,
activation_count: 50,
..Default::default()
};
let rarely_meta = crate::usage_stats::EnvStats {
last_activated: now - 700_000,
activation_count: 2,
..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).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_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");
let now = crate::usage_stats::now_timestamp();
let meta = crate::usage_stats::EnvStats {
last_activated: now,
activation_count: 1,
description: Some("Fix auth bug".to_string()),
cookbook: Some("github".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).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_falls_back_to_sync_when_no_cache(
context_object: (tempfile::TempDir, FakeContext, AdapterLog, NotificationLog),
) {
let (_temp_dir, mut context_object, _, _) = context_object;
context_object.cookbooks = vec![Box::new(FakeCookbook::new(
"git",
vec!["sync-repo"],
vec![],
))];
list_all(&mut context_object).unwrap();
let output = context_object.get_output();
assert!(
output.contains("git: sync-repo"),
"Should fall back to sync, got: {}",
output
);
}
}