use std::path::{Path, PathBuf};
use std::sync::Arc;
use algocline_core::ExecutionObserver;
use std::io::Write;
use crate::service::config::{AppConfig, LogDirSource};
use crate::service::path::{copy_dir, ContainedPath};
use crate::service::resolve::{make_require_code, packages_dir, resolve_code};
use crate::service::transcript::{append_note, write_transcript_log};
use crate::service::AppService;
#[test]
fn resolve_code_inline() {
let result = resolve_code(Some("return 1".into()), None);
assert_eq!(result.unwrap(), "return 1");
}
#[test]
fn resolve_code_from_file() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
write!(tmp, "return 42").unwrap();
let result = resolve_code(None, Some(tmp.path().to_string_lossy().into()));
assert_eq!(result.unwrap(), "return 42");
}
#[test]
fn resolve_code_both_provided_error() {
let result = resolve_code(Some("code".into()), Some("file.lua".into()));
let err = result.unwrap_err();
assert!(err.contains("not both"), "error: {err}");
}
#[test]
fn resolve_code_neither_provided_error() {
let result = resolve_code(None, None);
let err = result.unwrap_err();
assert!(err.contains("must be provided"), "error: {err}");
}
#[test]
fn resolve_code_nonexistent_file_error() {
let result = resolve_code(
None,
Some("/tmp/algocline_nonexistent_test_file.lua".into()),
);
assert!(result.is_err());
}
#[test]
fn make_require_code_basic() {
let code = make_require_code("ucb");
assert!(code.contains(r#"require("ucb")"#), "code: {code}");
assert!(code.contains("pkg.run(ctx)"), "code: {code}");
}
#[test]
fn make_require_code_different_names() {
for name in &["panel", "cot", "sc", "cove", "reflect", "calibrate"] {
let code = make_require_code(name);
assert!(
code.contains(&format!(r#"require("{name}")"#)),
"code for {name}: {code}"
);
}
}
#[test]
fn packages_dir_ends_with_expected_path() {
let dir = packages_dir().unwrap();
assert!(
dir.ends_with(".algocline/packages"),
"dir: {}",
dir.display()
);
}
#[test]
fn append_note_to_existing_log() {
let dir = tempfile::tempdir().unwrap();
let session_id = "s-test-001";
let log = serde_json::json!({
"session_id": session_id,
"stats": { "elapsed_ms": 100 },
"transcript": [],
});
let path = dir.path().join(format!("{session_id}.json"));
std::fs::write(&path, serde_json::to_string_pretty(&log).unwrap()).unwrap();
let count = append_note(dir.path(), session_id, "Step 2 was weak", Some("Step 2")).unwrap();
assert_eq!(count, 1);
let count = append_note(dir.path(), session_id, "Overall good", None).unwrap();
assert_eq!(count, 2);
let raw = std::fs::read_to_string(&path).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let notes = doc["notes"].as_array().unwrap();
assert_eq!(notes.len(), 2);
assert_eq!(notes[0]["content"], "Step 2 was weak");
assert_eq!(notes[0]["title"], "Step 2");
assert_eq!(notes[1]["content"], "Overall good");
assert!(notes[1]["title"].is_null());
assert!(notes[0]["timestamp"].is_number());
}
#[test]
fn append_note_missing_log_returns_error() {
let dir = tempfile::tempdir().unwrap();
let result = append_note(dir.path(), "s-nonexistent", "note", None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn log_list_from_dir() {
let dir = tempfile::tempdir().unwrap();
let log1 = serde_json::json!({
"session_id": "s-001",
"task_hint": "What is 2+2?",
"stats": { "elapsed_ms": 100, "rounds": 1, "llm_calls": 1 },
"transcript": [{ "prompt": "What is 2+2?", "response": "4" }],
});
let log2 = serde_json::json!({
"session_id": "s-002",
"task_hint": "Explain ownership",
"stats": { "elapsed_ms": 5000, "rounds": 3, "llm_calls": 3 },
"transcript": [],
"notes": [{ "timestamp": 0, "content": "good" }],
});
std::fs::write(
dir.path().join("s-001.json"),
serde_json::to_string(&log1).unwrap(),
)
.unwrap();
std::fs::write(
dir.path().join("s-002.json"),
serde_json::to_string(&log2).unwrap(),
)
.unwrap();
std::fs::write(dir.path().join("README.txt"), "ignore me").unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let entries = std::fs::read_dir(config.log_dir.as_ref().unwrap()).unwrap();
let mut count = 0;
for entry in entries.flatten() {
if entry.path().extension().and_then(|e| e.to_str()) == Some("json") {
count += 1;
}
}
assert_eq!(count, 2);
}
#[test]
fn contained_path_accepts_simple_name() {
let dir = tempfile::tempdir().unwrap();
let result = ContainedPath::child(dir.path(), "s-abc123.json");
assert!(result.is_ok());
assert!(result.unwrap().as_ref().ends_with("s-abc123.json"));
}
#[test]
fn contained_path_rejects_parent_traversal() {
let dir = tempfile::tempdir().unwrap();
let result = ContainedPath::child(dir.path(), "../../../etc/passwd");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("path traversal"), "err: {err}");
}
#[test]
fn contained_path_rejects_absolute_path() {
let dir = tempfile::tempdir().unwrap();
let result = ContainedPath::child(dir.path(), "/etc/passwd");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("path traversal"), "err: {err}");
}
#[test]
fn contained_path_rejects_dot_dot_in_middle() {
let dir = tempfile::tempdir().unwrap();
let result = ContainedPath::child(dir.path(), "foo/../bar");
assert!(result.is_err());
}
#[test]
fn contained_path_accepts_nested_normal() {
let dir = tempfile::tempdir().unwrap();
let result = ContainedPath::child(dir.path(), "sub/file.json");
assert!(result.is_ok());
}
#[test]
fn append_note_rejects_traversal_session_id() {
let dir = tempfile::tempdir().unwrap();
let result = append_note(dir.path(), "../../../etc/passwd", "evil", None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("path traversal"));
}
#[test]
fn write_transcript_log_creates_meta_file() {
let dir = tempfile::tempdir().unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let metrics = algocline_core::ExecutionMetrics::new();
let observer = metrics.create_observer();
observer.on_paused(&[algocline_core::LlmQuery {
id: algocline_core::QueryId::single(),
prompt: "What is 2+2?".into(),
system: None,
max_tokens: 100,
grounded: false,
underspecified: false,
}]);
observer.on_response_fed(&algocline_core::QueryId::single(), "4", None);
observer.on_resumed();
observer.on_completed(&serde_json::json!(null));
write_transcript_log(&config, "s-meta-test", &metrics, Some("ucb"));
assert!(dir.path().join("s-meta-test.json").exists());
let meta_path = dir.path().join("s-meta-test.meta.json");
assert!(meta_path.exists());
let raw = std::fs::read_to_string(&meta_path).unwrap();
let meta: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(meta["session_id"], "s-meta-test");
assert_eq!(meta["notes_count"], 0);
assert!(meta.get("elapsed_ms").is_some());
assert!(meta.get("rounds").is_some());
assert!(meta.get("llm_calls").is_some());
assert_eq!(meta["strategy"], "ucb");
assert!(meta.get("total_prompt_chars").is_some());
assert!(meta.get("total_response_chars").is_some());
assert!(meta.get("transcript").is_none());
let log_raw = std::fs::read_to_string(dir.path().join("s-meta-test.json")).unwrap();
let log: serde_json::Value = serde_json::from_str(&log_raw).unwrap();
assert_eq!(log["strategy"], "ucb");
}
#[test]
fn write_transcript_log_strategy_none() {
let dir = tempfile::tempdir().unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let metrics = algocline_core::ExecutionMetrics::new();
let observer = metrics.create_observer();
observer.on_paused(&[algocline_core::LlmQuery {
id: algocline_core::QueryId::single(),
prompt: "hello".into(),
system: None,
max_tokens: 100,
grounded: false,
underspecified: false,
}]);
observer.on_response_fed(&algocline_core::QueryId::single(), "world", None);
observer.on_resumed();
observer.on_completed(&serde_json::json!(null));
write_transcript_log(&config, "s-no-strat", &metrics, None);
let meta_path = dir.path().join("s-no-strat.meta.json");
let raw = std::fs::read_to_string(&meta_path).unwrap();
let meta: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(meta["strategy"].is_null());
}
#[test]
fn append_note_updates_meta_notes_count() {
let dir = tempfile::tempdir().unwrap();
let session_id = "s-meta-note";
let log = serde_json::json!({
"session_id": session_id,
"stats": { "elapsed_ms": 100 },
"transcript": [],
});
std::fs::write(
dir.path().join(format!("{session_id}.json")),
serde_json::to_string_pretty(&log).unwrap(),
)
.unwrap();
let meta = serde_json::json!({
"session_id": session_id,
"task_hint": "test",
"elapsed_ms": 100,
"rounds": 1,
"llm_calls": 1,
"notes_count": 0,
});
std::fs::write(
dir.path().join(format!("{session_id}.meta.json")),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
append_note(dir.path(), session_id, "first note", None).unwrap();
let raw = std::fs::read_to_string(dir.path().join(format!("{session_id}.meta.json"))).unwrap();
let updated: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(updated["notes_count"], 1);
append_note(dir.path(), session_id, "second note", None).unwrap();
let raw = std::fs::read_to_string(dir.path().join(format!("{session_id}.meta.json"))).unwrap();
let updated: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(updated["notes_count"], 2);
}
#[test]
fn transcript_config_default_enabled() {
let config = AppConfig {
log_dir: Some(PathBuf::from("/tmp/test")),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
assert!(config.log_enabled);
}
#[test]
fn write_transcript_log_disabled_is_noop() {
let dir = tempfile::tempdir().unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: false,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let metrics = algocline_core::ExecutionMetrics::new();
let observer = metrics.create_observer();
observer.on_paused(&[algocline_core::LlmQuery {
id: algocline_core::QueryId::single(),
prompt: "test".into(),
system: None,
max_tokens: 10,
grounded: false,
underspecified: false,
}]);
observer.on_response_fed(&algocline_core::QueryId::single(), "r", None);
observer.on_resumed();
observer.on_completed(&serde_json::json!(null));
write_transcript_log(&config, "s-disabled", &metrics, None);
assert!(!dir.path().join("s-disabled.json").exists());
assert!(!dir.path().join("s-disabled.meta.json").exists());
}
#[test]
fn write_transcript_log_empty_transcript_is_noop() {
let dir = tempfile::tempdir().unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let metrics = algocline_core::ExecutionMetrics::new();
write_transcript_log(&config, "s-empty", &metrics, None);
assert!(!dir.path().join("s-empty.json").exists());
}
#[test]
fn copy_dir_basic() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
std::fs::write(src.path().join("a.txt"), "hello").unwrap();
std::fs::create_dir(src.path().join("sub")).unwrap();
std::fs::write(src.path().join("sub/b.txt"), "world").unwrap();
let dst_path = dst.path().join("copied");
copy_dir(src.path(), &dst_path).unwrap();
assert_eq!(
std::fs::read_to_string(dst_path.join("a.txt")).unwrap(),
"hello"
);
assert_eq!(
std::fs::read_to_string(dst_path.join("sub/b.txt")).unwrap(),
"world"
);
}
#[test]
fn copy_dir_empty() {
let src = tempfile::tempdir().unwrap();
let dst = tempfile::tempdir().unwrap();
let dst_path = dst.path().join("empty_copy");
copy_dir(src.path(), &dst_path).unwrap();
assert!(dst_path.exists());
assert!(dst_path.is_dir());
}
#[test]
fn write_transcript_log_truncates_long_prompt() {
let dir = tempfile::tempdir().unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let metrics = algocline_core::ExecutionMetrics::new();
let observer = metrics.create_observer();
let long_prompt = "x".repeat(300);
observer.on_paused(&[algocline_core::LlmQuery {
id: algocline_core::QueryId::single(),
prompt: long_prompt,
system: None,
max_tokens: 10,
grounded: false,
underspecified: false,
}]);
observer.on_response_fed(&algocline_core::QueryId::single(), "r", None);
observer.on_resumed();
observer.on_completed(&serde_json::json!(null));
write_transcript_log(&config, "s-long", &metrics, None);
let raw = std::fs::read_to_string(dir.path().join("s-long.json")).unwrap();
let doc: serde_json::Value = serde_json::from_str(&raw).unwrap();
let hint = doc["task_hint"].as_str().unwrap();
assert!(hint.len() <= 104, "hint too long: {} chars", hint.len());
assert!(hint.ends_with("..."));
}
#[test]
fn log_list_prefers_meta_file() {
let dir = tempfile::tempdir().unwrap();
let log = serde_json::json!({
"session_id": "s-big",
"task_hint": "full log hint",
"stats": { "elapsed_ms": 999, "rounds": 5, "llm_calls": 5 },
"transcript": [{"prompt": "x".repeat(10000), "response": "y".repeat(10000)}],
});
std::fs::write(
dir.path().join("s-big.json"),
serde_json::to_string(&log).unwrap(),
)
.unwrap();
let meta = serde_json::json!({
"session_id": "s-big",
"task_hint": "full log hint",
"elapsed_ms": 999,
"rounds": 5,
"llm_calls": 5,
"notes_count": 0,
});
std::fs::write(
dir.path().join("s-big.meta.json"),
serde_json::to_string(&meta).unwrap(),
)
.unwrap();
let legacy = serde_json::json!({
"session_id": "s-legacy",
"task_hint": "legacy hint",
"stats": { "elapsed_ms": 100, "rounds": 1, "llm_calls": 1 },
"transcript": [],
});
std::fs::write(
dir.path().join("s-legacy.json"),
serde_json::to_string(&legacy).unwrap(),
)
.unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
let result = app.log_list(50).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
let sessions = parsed["sessions"].as_array().unwrap();
assert_eq!(sessions.len(), 2);
let ids: Vec<&str> = sessions
.iter()
.map(|s| s["session_id"].as_str().unwrap())
.collect();
assert!(ids.contains(&"s-big"));
assert!(ids.contains(&"s-legacy"));
}
#[test]
fn stats_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
let result = app.stats(None, None).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["total_sessions"], 0);
}
#[test]
fn stats_aggregates_by_strategy() {
let dir = tempfile::tempdir().unwrap();
let meta1 = serde_json::json!({
"session_id": "s-001", "strategy": "ucb",
"elapsed_ms": 1000, "llm_calls": 10, "rounds": 5,
"total_prompt_chars": 500, "total_response_chars": 300,
});
let meta2 = serde_json::json!({
"session_id": "s-002", "strategy": "ucb",
"elapsed_ms": 2000, "llm_calls": 12, "rounds": 6,
"total_prompt_chars": 600, "total_response_chars": 400,
});
let meta3 = serde_json::json!({
"session_id": "s-003", "strategy": "cove",
"elapsed_ms": 500, "llm_calls": 4, "rounds": 2,
"total_prompt_chars": 200, "total_response_chars": 150,
});
for (name, meta) in [("s-001", &meta1), ("s-002", &meta2), ("s-003", &meta3)] {
std::fs::write(
dir.path().join(format!("{name}.meta.json")),
serde_json::to_string(meta).unwrap(),
)
.unwrap();
}
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
let result = app.stats(None, None).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["total_sessions"], 3);
assert_eq!(parsed["strategies"]["ucb"]["count"], 2);
assert_eq!(parsed["strategies"]["ucb"]["avg_elapsed_ms"], 1500);
assert_eq!(parsed["strategies"]["ucb"]["avg_llm_calls"], 11);
assert_eq!(parsed["strategies"]["cove"]["count"], 1);
let result = app.stats(Some("ucb"), None).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["total_sessions"], 2);
assert!(parsed["strategies"]["cove"].is_null());
let result = app.stats(Some("nonexistent"), None).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["total_sessions"], 0);
}
#[test]
fn stats_legacy_logs_without_strategy() {
let dir = tempfile::tempdir().unwrap();
let legacy = serde_json::json!({
"session_id": "s-legacy",
"stats": { "elapsed_ms": 300, "llm_calls": 2, "rounds": 1,
"total_prompt_chars": 100, "total_response_chars": 50 },
"transcript": [],
});
std::fs::write(
dir.path().join("s-legacy.json"),
serde_json::to_string(&legacy).unwrap(),
)
.unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
let result = app.stats(None, None).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["total_sessions"], 1);
assert_eq!(parsed["strategies"]["unknown"]["count"], 1);
}
#[test]
fn info_returns_valid_json_with_expected_keys() {
let dir = tempfile::tempdir().unwrap();
let config = AppConfig {
log_dir: Some(dir.path().to_path_buf()),
log_dir_source: LogDirSource::Home,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
let result = app.info();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed["version"].is_string());
assert!(parsed["log_dir"]["resolved"].is_string());
assert_eq!(
parsed["log_dir"]["source"].as_str().unwrap(),
"~/.algocline/logs"
);
assert!(parsed["log_enabled"].as_bool().unwrap());
assert_eq!(parsed["tracing"].as_str().unwrap(), "file + stderr");
}
#[test]
fn info_stderr_only_when_no_log_dir() {
let config = AppConfig {
log_dir: None,
log_dir_source: LogDirSource::None,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
let result = app.info();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(parsed["log_dir"]["resolved"].is_null());
assert_eq!(
parsed["log_dir"]["source"].as_str().unwrap(),
"none (stderr only)"
);
assert_eq!(parsed["tracing"].as_str().unwrap(), "stderr only");
}
#[test]
fn require_log_dir_returns_path_when_present() {
let config = AppConfig {
log_dir: Some(PathBuf::from("/tmp/test-logs")),
log_dir_source: LogDirSource::EnvVar,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
assert_eq!(app.require_log_dir().unwrap(), Path::new("/tmp/test-logs"));
}
#[test]
fn require_log_dir_returns_err_when_none() {
let config = AppConfig {
log_dir: None,
log_dir_source: LogDirSource::None,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
assert!(app.require_log_dir().is_err());
}
#[test]
fn write_transcript_log_noop_when_log_dir_none() {
let dir = tempfile::tempdir().unwrap();
let config = AppConfig {
log_dir: None,
log_dir_source: LogDirSource::None,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let metrics = algocline_core::ExecutionMetrics::new();
let observer = metrics.create_observer();
observer.on_paused(&[algocline_core::LlmQuery {
id: algocline_core::QueryId::single(),
prompt: "test".into(),
system: None,
max_tokens: 10,
grounded: false,
underspecified: false,
}]);
observer.on_response_fed(&algocline_core::QueryId::single(), "r", None);
observer.on_resumed();
observer.on_completed(&serde_json::json!(null));
write_transcript_log(&config, "s-none-dir", &metrics, None);
assert!(!dir.path().join("s-none-dir.json").exists());
}
#[test]
fn log_dir_source_display_all_variants() {
assert_eq!(LogDirSource::EnvVar.to_string(), "ALC_LOG_DIR");
assert_eq!(LogDirSource::Home.to_string(), "~/.algocline/logs");
assert_eq!(LogDirSource::StateDir.to_string(), "state_dir");
assert_eq!(LogDirSource::CurrentDir.to_string(), "current_dir");
assert_eq!(LogDirSource::None.to_string(), "none (stderr only)");
}
#[test]
fn log_list_returns_empty_when_no_log_dir() {
let config = AppConfig {
log_dir: None,
log_dir_source: LogDirSource::None,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
let result = app.log_list(50).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["sessions"].as_array().unwrap().len(), 0);
}
#[test]
fn stats_returns_zero_when_no_log_dir() {
let config = AppConfig {
log_dir: None,
log_dir_source: LogDirSource::None,
log_enabled: true,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
};
let app = AppService {
executor: Arc::new(
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(async { algocline_engine::Executor::new(vec![]).await.unwrap() }),
),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: config,
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
};
let result = app.stats(None, None).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["total_sessions"].as_u64().unwrap(), 0);
}
async fn make_status_test_app() -> AppService {
AppService {
executor: Arc::new(algocline_engine::Executor::new(vec![]).await.unwrap()),
registry: Arc::new(algocline_engine::SessionRegistry::new()),
log_config: AppConfig {
log_dir: None,
log_dir_source: LogDirSource::None,
log_enabled: false,
prompt_preview_chars: algocline_engine::DEFAULT_PROMPT_PREVIEW_CHARS,
},
eval_sessions: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
session_strategies: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
search_paths: vec![],
}
}
#[tokio::test]
async fn status_without_filter_returns_empty_active_sessions() {
let app = make_status_test_app().await;
let out = app.status(None, None).await.unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["active_sessions"], 0);
assert_eq!(v["sessions"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn status_with_known_preset_string_ok() {
let app = make_status_test_app().await;
for preset in ["meta", "preview", "full"] {
let out = app
.status(None, Some(serde_json::json!(preset)))
.await
.unwrap_or_else(|e| panic!("preset '{preset}' should resolve, got err: {e}"));
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["active_sessions"], 0);
}
}
#[tokio::test]
async fn status_with_unknown_preset_returns_error() {
let app = make_status_test_app().await;
let err = app
.status(None, Some(serde_json::json!("bogus")))
.await
.expect_err("unknown preset must error");
assert!(
err.contains("unknown pending_filter preset"),
"err should explain the preset, got: {err}"
);
assert!(
err.contains("bogus"),
"err should echo the bad name, got: {err}"
);
}
#[tokio::test]
async fn status_with_custom_object_filter_ok() {
let app = make_status_test_app().await;
let filter = serde_json::json!({
"query_id": true,
"prompt": { "mode": "preview", "chars": 80 }
});
let out = app.status(None, Some(filter)).await.unwrap();
let v: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["active_sessions"], 0);
}
#[tokio::test]
async fn status_with_non_object_non_string_filter_errors() {
let app = make_status_test_app().await;
for (label, bad) in [
("null", serde_json::json!(null)),
("bool", serde_json::json!(true)),
("number", serde_json::json!(42)),
("array", serde_json::json!(["meta"])),
] {
let result = app.status(None, Some(bad)).await;
let err = result.expect_err(&format!("{label} filter must error"));
assert!(
err.contains("pending_filter must be a preset name"),
"err for {label} should explain shape, got: {err}"
);
assert!(
err.contains(label),
"err for {label} should name the bad type, got: {err}"
);
}
}