feat: support workspace-level sessions as well as global sessions

This commit is contained in:
Dark-Alex-17 committed 2026-09-28 13:29:25 -06:00
1 parent e7a7cd9704
commit c9fcea2849
8 files changed
+1965 -112

No files matched your search

+131 -14
View File
@@ -1,7 +1,9 @@
use crate::client::{ModelType, list_models};
use crate::config::paths;
use crate::config::{AppConfig, Config, list_agents_with_descriptions, list_sessions};
use crate::utils::list_file_names;
use crate::config::{
AppConfig, Config, labeled_session_names, list_agents_with_descriptions, sanitize_display_text,
session_scope_dirs,
};
use crate::vault::Vault;
use clap_complete::{CompletionCandidate, Shell, generate};
use clap_complete_nushell::Nushell;
@@ -115,7 +117,11 @@ pub(super) fn bundle_completer(current: &OsStr) -> Vec<CompletionCandidate> {
}
fn extract_agent_from_args() -> Option<String> {
let args: Vec<String> = env::args().collect();
extract_agent_from_args_in(env::args())
}
fn extract_agent_from_args_in<I: IntoIterator<Item = String>>(args: I) -> Option<String> {
let args: Vec<String> = args.into_iter().collect();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
@@ -134,19 +140,21 @@ fn extract_agent_from_args() -> Option<String> {
}
pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy();
session_candidates(
&current.to_string_lossy(),
extract_agent_from_args().as_deref(),
)
}
let sessions = if let Some(agent_name) = extract_agent_from_args() {
let sessions_dir = paths::agent_data_dir(&agent_name).join("sessions");
list_file_names(sessions_dir, ".yaml")
} else {
list_sessions()
};
sessions
fn session_candidates(cur: &str, agent: Option<&str>) -> Vec<CompletionCandidate> {
let (workspace_dir, global_dir) = session_scope_dirs(agent);
labeled_session_names(&workspace_dir, &global_dir)
.into_iter()
.filter(|s| s.starts_with(&*cur))
.map(CompletionCandidate::new)
.filter(|(name, _)| name.starts_with(cur))
.map(|(name, scope)| {
CompletionCandidate::new(sanitize_display_text(&name))
.help(Some(scope.to_string().into()))
})
.collect()
}
@@ -194,3 +202,112 @@ pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
Err(_) => vec![],
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::default_sessions_dir;
use crate::testing::{EnvVarGuard, TestConfigDirGuard};
use serial_test::serial;
use std::path::Path;
fn seed(dir: &Path, name: &str) {
fs::create_dir_all(dir).unwrap();
fs::write(dir.join(format!("{name}.yaml")), "model: x\n").unwrap();
}
fn labeled(candidates: Vec<CompletionCandidate>) -> Vec<(String, Option<String>)> {
candidates
.into_iter()
.map(|c| {
(
c.get_value().to_string_lossy().into_owned(),
c.get_help().map(|h| h.to_string()),
)
})
.collect()
}
#[test]
#[serial]
fn session_completer_labels_scope_and_prefers_workspace() {
let _guard = TestConfigDirGuard::new("cli-session-completer");
seed(&paths::workspace_sessions_dir(), "both");
seed(&default_sessions_dir(), "both");
seed(&default_sessions_dir(), "only-global");
seed(&paths::workspace_sessions_dir(), "only-ws");
assert_eq!(
labeled(session_completer(OsStr::new(""))),
vec![
("both".to_string(), Some("workspace".to_string())),
("only-ws".to_string(), Some("workspace".to_string())),
("only-global".to_string(), Some("global".to_string())),
]
);
assert_eq!(
labeled(session_completer(OsStr::new("on"))),
vec![
("only-ws".to_string(), Some("workspace".to_string())),
("only-global".to_string(), Some("global".to_string())),
]
);
}
#[test]
#[serial]
fn session_candidates_use_the_agent_scope_dirs() {
let _guard = TestConfigDirGuard::new("cli-agent-session-completer");
let _agent_data_dir = EnvVarGuard::unset("A_DATA_DIR");
seed(&paths::workspace_agent_sessions_dir("a"), "both");
seed(&paths::agent_sessions_dir("a"), "both");
seed(&paths::agent_sessions_dir("a"), "agent-global");
seed(&default_sessions_dir(), "plain-global");
assert_eq!(
labeled(session_candidates("", Some("a"))),
vec![
("both".to_string(), Some("workspace".to_string())),
("agent-global".to_string(), Some("global".to_string())),
]
);
assert_eq!(
labeled(session_candidates("", None)),
vec![("plain-global".to_string(), Some("global".to_string()))]
);
}
#[test]
fn extract_agent_from_args_in_reads_both_flag_forms() {
fn args(s: &str) -> Vec<String> {
s.split_whitespace().map(str::to_string).collect()
}
assert_eq!(
extract_agent_from_args_in(args("coyote --agent a --session x")),
Some("a".to_string())
);
assert_eq!(
extract_agent_from_args_in(args("coyote -a b")),
Some("b".to_string())
);
assert_eq!(
extract_agent_from_args_in(args("coyote --agent=c")),
Some("c".to_string())
);
assert_eq!(extract_agent_from_args_in(args("coyote --agent")), None);
assert_eq!(extract_agent_from_args_in(args("coyote --session x")), None);
}
#[test]
#[serial]
fn config_dir_guard_clears_and_restores_the_sessions_dir_override() {
let key = crate::utils::get_env_name("sessions_dir");
let junk = std::path::PathBuf::from("/nonexistent/junk-sessions");
let _outer = EnvVarGuard::set(&key, &junk);
{
let guard = TestConfigDirGuard::new("cli-sessions-dir-guard");
assert!(default_sessions_dir().starts_with(&guard.path));
}
assert_eq!(default_sessions_dir(), junk);
}
}
+2 -6
View File
@@ -58,7 +58,7 @@ pub use self::request_context::{
pub use self::role::{
CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, Role, RoleLike, SHELL_ROLE,
};
pub use self::session::Session;
pub use self::session::{Session, SessionScope, labeled_session_names, session_scope_dirs};
#[allow(unused_imports)]
pub use self::skill::Skill;
#[allow(unused_imports)]
@@ -183,7 +183,7 @@ const MCP_FILE_NAME: &str = "mcp.json";
const HIDDEN_MCP_FILE_NAME: &str = ".mcp.json";
const MEMORY_DIR_NAME: &str = "memory";
const MEMORY_INDEX_FILE_NAME: &str = "MEMORY.md";
const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
pub(crate) const WORKSPACE_COYOTE_DIR_NAME: &str = ".coyote";
const SBX_KIT_DIR_NAME: &str = "sbx-kit";
const SBX_KIT_HASH_FILE: &str = "kit.sha256";
const SBX_MIXIN_FILE_NAME: &str = "sbx-mixin.yaml";
@@ -583,10 +583,6 @@ pub fn default_sessions_dir() -> PathBuf {
}
}
pub fn list_sessions() -> Vec<String> {
list_file_names(default_sessions_dir(), ".yaml")
}
/// Refreshes the models override. With a URL, mirrors a full models.yaml from
/// it; without one, rebuilds the catalog from the live models.dev + OpenRouter
/// APIs merged with the embedded quirks.
+31 -1
View File
@@ -5,7 +5,7 @@ use super::{
GLOBAL_TOOLS_UTILS_DIR_NAME, HIDDEN_MCP_FILE_NAME, HOOKS_DIR_NAME, INSTALLED_BUNDLES_FILE_NAME,
MACROS_DIR_NAME, MCP_FILE_NAME, MEMORY_DIR_NAME, MEMORY_INDEX_FILE_NAME, ModelsOverride,
RAGS_DIR_NAME, ROLES_DIR_NAME, SBX_KIT_DIR_NAME, SBX_KIT_HASH_FILE, SBX_MIXIN_FILE_NAME,
SBX_MIXIN_KITS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
SBX_MIXIN_KITS_DIR_NAME, SESSIONS_DIR_NAME, SKILLS_DIR_NAME, WORKSPACE_COYOTE_DIR_NAME,
};
use crate::client::ProviderModels;
use crate::config::REPL_HISTORY_DIR_NAME;
@@ -229,6 +229,17 @@ pub fn workspace_macros_dir() -> PathBuf {
workspace_config_dir().join(MACROS_DIR_NAME)
}
pub fn workspace_sessions_dir() -> PathBuf {
workspace_config_dir().join(SESSIONS_DIR_NAME)
}
pub fn workspace_agent_sessions_dir(name: &str) -> PathBuf {
workspace_config_dir()
.join(AGENTS_DIR_NAME)
.join(name)
.join(SESSIONS_DIR_NAME)
}
pub fn workspace_mcp_config_file() -> Option<PathBuf> {
workspace_mcp_config_file_in(&env::current_dir().unwrap_or_default())
}
@@ -332,6 +343,10 @@ pub fn agent_data_dir(name: &str) -> PathBuf {
}
}
pub fn agent_sessions_dir(name: &str) -> PathBuf {
agent_data_dir(name).join(SESSIONS_DIR_NAME)
}
pub fn agent_graph_file(agent_name: &str) -> PathBuf {
agent_data_dir(agent_name).join(AGENT_GRAPH_FILE_NAME)
}
@@ -854,6 +869,21 @@ mod tests {
);
});
}
#[test]
#[serial]
fn workspace_session_dirs_live_under_workspace_config_dir() {
with_workspace_dir(|_, ws_dir| {
assert_eq!(workspace_sessions_dir(), ws_dir.join(SESSIONS_DIR_NAME));
assert_eq!(
workspace_agent_sessions_dir("helper"),
ws_dir
.join(AGENTS_DIR_NAME)
.join("helper")
.join(SESSIONS_DIR_NAME)
);
});
}
}
mod user_mcp_resolution {
File diff suppressed because it is too large. Load diff
+210 -6
View File
@@ -12,8 +12,10 @@ use inquire::{Confirm, Text, validator::Validation};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::fs::{read_to_string, write};
use std::path::Path;
use std::fmt;
use std::fs::{OpenOptions, read_to_string, remove_file, rename};
use std::io::{self, Write as _};
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
static RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\d{8}T\d{6}-").unwrap());
@@ -29,6 +31,62 @@ fn cost_is_zero(v: &f64) -> bool {
*v == 0.0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SessionScope {
#[default]
Global,
Workspace,
}
impl fmt::Display for SessionScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SessionScope::Global => write!(f, "global"),
SessionScope::Workspace => write!(f, "workspace"),
}
}
}
pub fn session_scope_dirs(agent: Option<&str>) -> (PathBuf, PathBuf) {
match agent {
Some(agent) => (
paths::workspace_agent_sessions_dir(agent),
paths::agent_sessions_dir(agent),
),
None => (paths::workspace_sessions_dir(), default_sessions_dir()),
}
}
/// Session names across both scope dirs, workspace first, deduped by name so
/// a workspace session shadows a global one with the same name.
pub fn labeled_session_names(
workspace_dir: &Path,
global_dir: &Path,
) -> Vec<(String, SessionScope)> {
let mut values = Vec::new();
let mut seen = HashSet::new();
for (dir, scope) in [
(workspace_dir, SessionScope::Workspace),
(global_dir, SessionScope::Global),
] {
for name in list_file_names(dir, ".yaml") {
if seen.insert(name.clone()) {
values.push((name, scope));
}
}
}
values
}
pub fn merged_session_names(workspace_dir: &Path, global_dir: &Path) -> Vec<String> {
let mut names: Vec<String> = labeled_session_names(workspace_dir, global_dir)
.into_iter()
.map(|(name, _)| name)
.collect();
names.sort_unstable();
names
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Session {
#[serde(rename(serialize = "model", deserialize = "model"))]
@@ -120,6 +178,8 @@ pub struct Session {
#[serde(skip)]
path: Option<String>,
#[serde(skip)]
scope: SessionScope,
#[serde(skip)]
dirty: bool,
#[serde(skip)]
save_session_this_time: bool,
@@ -173,6 +233,7 @@ impl Session {
app: &AppConfig,
name: &str,
path: &Path,
scope: SessionScope,
) -> Result<Self> {
let content = read_to_string(path)
.with_context(|| format!("Failed to load session {} at {}", name, path.display()))?;
@@ -180,6 +241,7 @@ impl Session {
serde_yaml::from_str(&content).with_context(|| format!("Invalid session {name}"))?;
session.model = Model::retrieve_model(app, &session.model_id, ModelType::Chat)?;
session.scope = scope;
if let Some(autoname) = name.strip_prefix("_/") {
session.name = TEMP_SESSION_NAME.to_string();
@@ -250,6 +312,14 @@ impl Session {
self.name = name;
}
pub fn scope(&self) -> SessionScope {
self.scope
}
pub fn set_scope(&mut self, scope: SessionScope) {
self.scope = scope;
}
pub fn clear_autoname(&mut self) {
self.autoname = None;
}
@@ -313,6 +383,7 @@ impl Session {
pub fn export(&self) -> Result<String> {
let mut data = json!({
"path": self.path,
"scope": self.scope.to_string(),
"model": self.model().id(),
});
if let Some(temperature) = self.temperature() {
@@ -403,6 +474,7 @@ impl Session {
if let Some(path) = &self.path {
items.push(("path", path.to_string()));
}
items.push(("scope", self.scope.to_string()));
if let Some(autoname) = self.autoname() {
items.push(("autoname", autoname.to_string()));
@@ -886,17 +958,52 @@ impl Session {
pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {
ensure_parent_exists(session_path)?;
self.path = Some(session_path.display().to_string());
let content = serde_yaml::to_string(&self)
.with_context(|| format!("Failed to serde session '{}'", self.name))?;
write(session_path, content).with_context(|| {
// Write through a sibling temp file and rename it over the target so a
// symlink planted at the session path is replaced, never followed. The
// temp file is opened with create_new so a symlink planted at the temp
// path is not followed either; a stale entry there is removed and the
// open retried once.
let mut tmp_name = session_path.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", process::id()));
let tmp_path = PathBuf::from(tmp_name);
let write_context = || {
format!(
"Failed to write session '{}' to '{}'",
self.name,
session_path.display()
)
})?;
};
let open_tmp = || {
OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp_path)
};
let mut tmp_file = match open_tmp() {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
remove_file(&tmp_path).with_context(write_context)?;
open_tmp().with_context(write_context)?
}
Err(error) => return Err(error).with_context(write_context),
};
if let Err(error) = tmp_file
.write_all(content.as_bytes())
.and_then(|_| tmp_file.flush())
{
drop(tmp_file);
let _ = remove_file(&tmp_path);
return Err(error).with_context(write_context);
}
drop(tmp_file);
if let Err(error) = rename(&tmp_path, session_path) {
let _ = remove_file(&tmp_path);
return Err(error).with_context(write_context);
}
self.path = Some(session_path.display().to_string());
if is_repl {
println!("✓ Saved the session to '{}'.", session_path.display());
@@ -1317,6 +1424,16 @@ mod tests {
);
}
#[test]
fn session_scope_is_not_serialized() {
let mut session = Session::default();
session.set_scope(SessionScope::Workspace);
let yaml = serde_yaml::to_string(&session).unwrap();
assert!(!yaml.contains("scope"));
}
#[test]
fn session_deserialization_ignores_injected_role_hooks() {
// A session file is attacker-writable data: a `role_hooks:` key
@@ -1356,8 +1473,10 @@ mod tests {
fn session_export_includes_real_usage_only_when_reported() {
let empty = Session::default().export().unwrap();
assert!(!empty.contains("real_input_tokens"));
assert!(empty.contains("scope: global"));
let mut session = Session::default();
session.set_scope(SessionScope::Workspace);
session.accumulate_token_usage(&TokenUsage {
input_tokens: Some(5),
output_tokens: Some(7),
@@ -1365,12 +1484,97 @@ mod tests {
cache_read_input_tokens: Some(3),
});
let exported = session.export().unwrap();
assert!(exported.contains("scope: workspace"));
assert!(exported.contains("real_input_tokens: 5"));
assert!(exported.contains("real_output_tokens: 7"));
assert!(exported.contains("cache_read_tokens: 3"));
assert!(!exported.contains("cache_creation_tokens"));
}
fn rendered_lines(session: &Session) -> Vec<String> {
let options = crate::render::RenderOptions {
raw_markdown: true,
..Default::default()
};
let mut render = MarkdownRender::init(options).unwrap();
session
.render(&mut render, &None)
.unwrap()
.lines()
.map(str::to_string)
.collect()
}
#[test]
fn session_render_reports_scope_line() {
let mut session = Session::default();
assert!(
rendered_lines(&session)
.iter()
.any(|line| line.starts_with("scope") && line.ends_with("global"))
);
session.set_scope(SessionScope::Workspace);
assert!(
rendered_lines(&session)
.iter()
.any(|line| line.starts_with("scope") && line.ends_with("workspace"))
);
}
#[cfg(unix)]
#[test]
fn session_save_replaces_a_symlink_instead_of_following_it() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("coyote-session-save-symlink-{unique}"));
let sessions_dir = dir.join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
let target = dir.join("target.txt");
std::fs::write(&target, "untouched").unwrap();
let link = sessions_dir.join("link.yaml");
std::os::unix::fs::symlink(&target, &link).unwrap();
let mut session = Session::default();
session.save("link", &link, false).unwrap();
assert_eq!(read_to_string(&target).unwrap(), "untouched");
assert!(link.symlink_metadata().unwrap().is_file());
assert!(read_to_string(&link).unwrap().contains("model:"));
assert_eq!(session.name(), "link");
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn session_save_does_not_follow_a_symlink_planted_at_the_temp_path() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("coyote-session-save-tmp-symlink-{unique}"));
let sessions_dir = dir.join("sessions");
std::fs::create_dir_all(&sessions_dir).unwrap();
let target = dir.join("target.txt");
std::fs::write(&target, "untouched").unwrap();
let destination = sessions_dir.join("notes.yaml");
let mut tmp_name = destination.as_os_str().to_os_string();
tmp_name.push(format!(".{}.tmp", process::id()));
let tmp_path = PathBuf::from(tmp_name);
std::os::unix::fs::symlink(&target, &tmp_path).unwrap();
let mut session = Session::default();
session.save("notes", &destination, false).unwrap();
assert_eq!(read_to_string(&target).unwrap(), "untouched");
assert!(destination.symlink_metadata().unwrap().is_file());
assert!(read_to_string(&destination).unwrap().contains("model:"));
assert!(!tmp_path.exists(), "temp file must not be left behind");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn session_todo_state_survives_yaml_round_trip() {
let mut session = Session::default();
+212 -5
View File
@@ -13,7 +13,8 @@ use crate::client::{
};
use crate::config::{
AgentVariables, AppConfig, AssertState, Input, LastMessage, MacroState, RequestContext,
StateFlags, flatten_prompt_messages, macro_execute, resolve_prompt_args, sanitize_display_text,
SessionScope, StateFlags, flatten_prompt_messages, macro_execute, resolve_prompt_args,
sanitize_display_text,
};
use crate::config::{AssetCategory, paths};
use crate::function::agents::{GuardrailAction, check_pending_tasks_guardrail};
@@ -1088,11 +1089,24 @@ pub async fn run_repl_command(
Some(("role", name)) => {
ctx.save_role(name)?;
}
Some(("session", name)) => {
ctx.save_session(name)?;
}
Some(("session", rest)) => match parse_repl_save_session(rest) {
ReplSaveSessionDispatch::Run { name, scope } => {
ctx.save_session(name.as_deref(), scope)?;
}
ReplSaveSessionDispatch::Help => println!("{}", repl_save_session_help()),
ReplSaveSessionDispatch::Conflict => {
println!("--workspace and --global are mutually exclusive")
}
ReplSaveSessionDispatch::Usage => {
println!(
"Usage: .save session [name] [--workspace|--global] (see `.save session --help`)"
)
}
},
_ => {
println!(r#"Usage: .save <role|session> [name]"#)
println!(
r#"Usage: .save role [name] | .save session [name] [--workspace|--global]"#
)
}
},
".edit" => {
@@ -1934,6 +1948,61 @@ fn parse_repl_uninstall(args: Option<&str>) -> ReplUninstallDispatch {
}
}
fn repl_save_session_help() -> String {
r#"Save the current session to a file.
Usage:
.save session [name] [--workspace|--global]
Flags:
--workspace Save under .coyote/ in the current workspace
--global Save under the global config dir
Without a flag the session is re-saved wherever it currently lives; a brand-new
session lives in the global scope. Re-homing a session never deletes the old file."#
.to_string()
}
#[derive(Debug, PartialEq)]
enum ReplSaveSessionDispatch {
Run {
name: Option<String>,
scope: Option<SessionScope>,
},
Help,
Conflict,
Usage,
}
fn parse_repl_save_session(args: Option<&str>) -> ReplSaveSessionDispatch {
let tokens: Vec<&str> = args.unwrap_or("").split_whitespace().collect();
if tokens
.iter()
.any(|token| *token == "--help" || *token == "-h")
{
return ReplSaveSessionDispatch::Help;
}
let mut scope = None;
let mut names = Vec::new();
for token in tokens {
let requested = match token {
"--workspace" => SessionScope::Workspace,
"--global" => SessionScope::Global,
other if other.starts_with('-') => return ReplSaveSessionDispatch::Usage,
other => {
names.push(other);
continue;
}
};
if scope.is_some_and(|current| current != requested) {
return ReplSaveSessionDispatch::Conflict;
}
scope = Some(requested);
}
let name = (!names.is_empty()).then(|| names.join(" "));
ReplSaveSessionDispatch::Run { name, scope }
}
pub fn builtin_command_names() -> Vec<&'static str> {
let mut names: Vec<&'static str> = REPL_COMMANDS
.iter()
@@ -3207,6 +3276,144 @@ mod tests {
assert_eq!(parse_repl_uninstall(None), ReplUninstallDispatch::Usage);
}
#[test]
fn parse_repl_save_session_routes_name_and_scope_in_either_order() {
assert_eq!(
parse_repl_save_session(None),
ReplSaveSessionDispatch::Run {
name: None,
scope: None
}
);
assert_eq!(
parse_repl_save_session(Some("notes")),
ReplSaveSessionDispatch::Run {
name: Some("notes".to_string()),
scope: None
}
);
assert_eq!(
parse_repl_save_session(Some("notes --workspace")),
ReplSaveSessionDispatch::Run {
name: Some("notes".to_string()),
scope: Some(SessionScope::Workspace)
}
);
assert_eq!(
parse_repl_save_session(Some("--global notes")),
ReplSaveSessionDispatch::Run {
name: Some("notes".to_string()),
scope: Some(SessionScope::Global)
}
);
assert_eq!(
parse_repl_save_session(Some("--workspace")),
ReplSaveSessionDispatch::Run {
name: None,
scope: Some(SessionScope::Workspace)
}
);
}
#[test]
fn parse_repl_save_session_rejects_conflicts_and_unknown_flags_but_joins_spaced_names() {
assert_eq!(
parse_repl_save_session(Some("--workspace --global")),
ReplSaveSessionDispatch::Conflict
);
assert_eq!(
parse_repl_save_session(Some("notes --force")),
ReplSaveSessionDispatch::Usage
);
assert_eq!(
parse_repl_save_session(Some("one two")),
ReplSaveSessionDispatch::Run {
name: Some("one two".to_string()),
scope: None
}
);
assert_eq!(
parse_repl_save_session(Some("--help")),
ReplSaveSessionDispatch::Help
);
assert_eq!(
parse_repl_save_session(Some("notes -h")),
ReplSaveSessionDispatch::Help
);
assert_eq!(
parse_repl_save_session(Some("--workspace --global -h")),
ReplSaveSessionDispatch::Help
);
assert_eq!(
parse_repl_save_session(Some("--global notes --global")),
ReplSaveSessionDispatch::Run {
name: Some("notes".to_string()),
scope: Some(SessionScope::Global)
}
);
}
#[test]
fn repl_save_session_help_text_covers_the_full_surface() {
let help = repl_save_session_help();
for needle in [
"--workspace",
"--global",
"[name]",
"never deletes the old file",
] {
assert!(help.contains(needle), "save session help missing {needle}");
}
}
// Usage probe (spec-first, criterion 5): `-h/--help` wins from ANY
// position — including when it is preceded by a token that would
// otherwise route to Usage (unknown `-` flag) or Conflict (both scope
// flags) — while, absent help, an unknown `-` token still routes to
// Usage even when a valid scope flag is present, and a repeated
// identical flag stays idempotent for `--workspace` too.
#[test]
fn usage_probe_save_session_help_prescan_beats_usage_and_conflict() {
for input in [
"--bogus -h",
"-h --bogus",
"notes --bogus --help",
"--workspace --global notes -h",
"--help --workspace --global",
"one -h two",
] {
assert_eq!(
parse_repl_save_session(Some(input)),
ReplSaveSessionDispatch::Help,
"help must win for {input:?}"
);
}
assert_eq!(
parse_repl_save_session(Some("--workspace --bogus notes")),
ReplSaveSessionDispatch::Usage
);
assert_eq!(
parse_repl_save_session(Some("-x")),
ReplSaveSessionDispatch::Usage
);
assert_eq!(
parse_repl_save_session(Some("--workspace notes --workspace")),
ReplSaveSessionDispatch::Run {
name: Some("notes".to_string()),
scope: Some(SessionScope::Workspace)
}
);
// Non-flag tokens join into ONE name even when a flag sits between them.
assert_eq!(
parse_repl_save_session(Some("one --global two")),
ReplSaveSessionDispatch::Run {
name: Some("one two".to_string()),
scope: Some(SessionScope::Global)
}
);
}
#[test]
fn repl_install_and_uninstall_help_text_cover_the_full_surface() {
let install = repl_install_help();
+28 -5
View File
@@ -1,7 +1,10 @@
//! Test-only helpers shared across modules' unit tests.
use crate::config::WORKSPACE_COYOTE_DIR_NAME;
use crate::utils;
use log::{Level, LevelFilter, Log, Metadata, Record};
use std::ffi::{OsStr, OsString};
use std::path::PathBuf;
use std::sync::{Mutex, Once, OnceLock};
struct TestLogCollector;
@@ -147,12 +150,19 @@ impl Drop for EnvVarGuard {
}
}
/// Points the config dir at a fresh temp directory for the guard's lifetime
/// and removes it on drop, including on panic. Tests using it must serialize
/// (`#[serial]`) — the config-dir env var is process-global.
/// Points the config dir and the workspace config dir at fresh temp
/// directories for the guard's lifetime and removes them on drop, including
/// on panic, so the developer's real `./.coyote/` never leaks into a test.
/// The sessions-dir override is cleared too, so Global-scope session writes
/// land under the temp config dir rather than the developer's real one.
/// Tests using it must serialize (`#[serial]`) — the env vars are
/// process-global.
pub(crate) struct TestConfigDirGuard {
_env: EnvVarGuard,
pub(crate) path: std::path::PathBuf,
_workspace_env: EnvVarGuard,
_sessions_env: EnvVarGuard,
pub(crate) path: PathBuf,
workspace_path: PathBuf,
}
impl TestConfigDirGuard {
@@ -163,9 +173,19 @@ impl TestConfigDirGuard {
.as_nanos();
let path = std::env::temp_dir().join(format!("coyote-{label}-{unique}"));
std::fs::create_dir_all(&path).unwrap();
let workspace_path = std::env::temp_dir()
.join(format!("coyote-{label}-ws-{unique}"))
.join(WORKSPACE_COYOTE_DIR_NAME);
std::fs::create_dir_all(&workspace_path).unwrap();
Self {
_env: EnvVarGuard::set(crate::utils::get_env_name("config_dir"), &path),
_env: EnvVarGuard::set(utils::get_env_name("config_dir"), &path),
_workspace_env: EnvVarGuard::set(
utils::get_env_name("workspace_config_dir"),
&workspace_path,
),
_sessions_env: EnvVarGuard::unset(utils::get_env_name("sessions_dir")),
path,
workspace_path,
}
}
}
@@ -173,5 +193,8 @@ impl TestConfigDirGuard {
impl Drop for TestConfigDirGuard {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
if let Some(root) = self.workspace_path.parent() {
let _ = std::fs::remove_dir_all(root);
}
}
}