2026-03-27 16:05:42 +00:00
|
|
|
use crate::state::SessionState;
|
|
|
|
|
use crate::store::StoreOps;
|
|
|
|
|
use serde_json::json;
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
|
|
|
|
use super::scaffold::scaffold_story_kit;
|
|
|
|
|
|
|
|
|
|
const KEY_LAST_PROJECT: &str = "last_project_path";
|
|
|
|
|
const KEY_KNOWN_PROJECTS: &str = "known_projects";
|
|
|
|
|
|
|
|
|
|
/// Validate that a path exists and is a directory (pure function for testing)
|
|
|
|
|
async fn validate_project_path(path: PathBuf) -> Result<(), String> {
|
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
return Err(format!("Path does not exist: {}", path.display()));
|
|
|
|
|
}
|
|
|
|
|
if !path.is_dir() {
|
|
|
|
|
return Err(format!("Path is not a directory: {}", path.display()));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("Task failed: {}", e))?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub(crate) async fn ensure_project_root_with_story_kit(
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
port: u16,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
|
|
|
if !path.exists() {
|
|
|
|
|
fs::create_dir_all(&path)
|
|
|
|
|
.map_err(|e| format!("Failed to create project directory: {}", e))?;
|
|
|
|
|
}
|
2026-04-03 16:12:52 +01:00
|
|
|
if !path.join(".huskies").is_dir() {
|
2026-03-27 16:05:42 +00:00
|
|
|
scaffold_story_kit(&path, port)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
})
|
|
|
|
|
.await
|
|
|
|
|
.map_err(|e| format!("Task failed: {}", e))?
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn open_project(
|
|
|
|
|
path: String,
|
|
|
|
|
state: &SessionState,
|
|
|
|
|
store: &dyn StoreOps,
|
|
|
|
|
port: u16,
|
|
|
|
|
) -> Result<String, String> {
|
|
|
|
|
let p = PathBuf::from(&path);
|
|
|
|
|
|
|
|
|
|
ensure_project_root_with_story_kit(p.clone(), port).await?;
|
|
|
|
|
validate_project_path(p.clone()).await?;
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
// TRACE:MERGE-DEBUG — remove once root cause is found
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[MERGE-DEBUG] open_project: setting project_root to {:?}",
|
|
|
|
|
p
|
|
|
|
|
);
|
|
|
|
|
let mut root = state.project_root.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
*root = Some(p);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
store.set(KEY_LAST_PROJECT, json!(path));
|
|
|
|
|
|
|
|
|
|
let mut known_projects = get_known_projects(store)?;
|
|
|
|
|
|
|
|
|
|
known_projects.retain(|p| p != &path);
|
|
|
|
|
known_projects.insert(0, path.clone());
|
|
|
|
|
store.set(KEY_KNOWN_PROJECTS, json!(known_projects));
|
|
|
|
|
|
|
|
|
|
store.save()?;
|
|
|
|
|
|
|
|
|
|
Ok(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn close_project(state: &SessionState, store: &dyn StoreOps) -> Result<(), String> {
|
|
|
|
|
{
|
|
|
|
|
// TRACE:MERGE-DEBUG — remove once root cause is found
|
|
|
|
|
crate::slog!("[MERGE-DEBUG] close_project: setting project_root to None");
|
|
|
|
|
let mut root = state.project_root.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
*root = None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
store.delete(KEY_LAST_PROJECT);
|
|
|
|
|
store.save()?;
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn get_current_project(
|
|
|
|
|
state: &SessionState,
|
|
|
|
|
store: &dyn StoreOps,
|
|
|
|
|
) -> Result<Option<String>, String> {
|
|
|
|
|
{
|
|
|
|
|
let root = state.project_root.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
if let Some(path) = &*root {
|
|
|
|
|
return Ok(Some(path.to_string_lossy().to_string()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(path_str) = store
|
|
|
|
|
.get(KEY_LAST_PROJECT)
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|val| val.as_str())
|
|
|
|
|
{
|
|
|
|
|
let p = PathBuf::from(path_str);
|
|
|
|
|
if p.exists() && p.is_dir() {
|
|
|
|
|
// TRACE:MERGE-DEBUG — remove once root cause is found
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[MERGE-DEBUG] get_current_project: project_root was None, \
|
|
|
|
|
restoring from store to {:?}",
|
|
|
|
|
p
|
|
|
|
|
);
|
|
|
|
|
let mut root = state.project_root.lock().map_err(|e| e.to_string())?;
|
|
|
|
|
*root = Some(p);
|
|
|
|
|
return Ok(Some(path_str.to_string()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(None)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn get_known_projects(store: &dyn StoreOps) -> Result<Vec<String>, String> {
|
|
|
|
|
let projects = store
|
|
|
|
|
.get(KEY_KNOWN_PROJECTS)
|
|
|
|
|
.and_then(|val| val.as_array().cloned())
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|val| val.as_str().map(|s| s.to_string()))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
Ok(projects)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn forget_known_project(path: String, store: &dyn StoreOps) -> Result<(), String> {
|
|
|
|
|
let mut known_projects = get_known_projects(store)?;
|
|
|
|
|
let original_len = known_projects.len();
|
|
|
|
|
|
|
|
|
|
known_projects.retain(|p| p != &path);
|
|
|
|
|
|
|
|
|
|
if known_projects.len() == original_len {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
store.set(KEY_KNOWN_PROJECTS, json!(known_projects));
|
|
|
|
|
store.save()?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use crate::store::JsonFileStore;
|
|
|
|
|
use tempfile::tempdir;
|
|
|
|
|
|
|
|
|
|
fn make_store(dir: &tempfile::TempDir) -> JsonFileStore {
|
|
|
|
|
JsonFileStore::new(dir.path().join("test_store.json")).unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn make_state_with_root(path: PathBuf) -> SessionState {
|
|
|
|
|
let state = SessionState::default();
|
|
|
|
|
{
|
|
|
|
|
let mut root = state.project_root.lock().unwrap();
|
|
|
|
|
*root = Some(path);
|
|
|
|
|
}
|
|
|
|
|
state
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- open/close/get project ---
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_project_sets_root_and_persists() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let project_dir = dir.path().join("myproject");
|
|
|
|
|
fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = SessionState::default();
|
|
|
|
|
|
|
|
|
|
let result = open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001).await;
|
|
|
|
|
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
let root = state.get_project_root().unwrap();
|
|
|
|
|
assert_eq!(root, project_dir);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_project_does_not_overwrite_existing_mcp_json() {
|
|
|
|
|
// scaffold must NOT overwrite .mcp.json when it already exists — QA
|
|
|
|
|
// test servers share the real project root, and re-writing would
|
|
|
|
|
// clobber the file with the wrong port.
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let project_dir = dir.path().join("myproject");
|
|
|
|
|
fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
|
// Pre-write .mcp.json with a different port to simulate an already-configured project.
|
|
|
|
|
let mcp_path = project_dir.join(".mcp.json");
|
|
|
|
|
fs::write(&mcp_path, "{\"existing\": true}").unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = SessionState::default();
|
|
|
|
|
|
|
|
|
|
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
fs::read_to_string(&mcp_path).unwrap(),
|
|
|
|
|
"{\"existing\": true}",
|
|
|
|
|
"open_project must not overwrite an existing .mcp.json"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_project_writes_mcp_json_when_missing() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let project_dir = dir.path().join("myproject");
|
|
|
|
|
fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = SessionState::default();
|
|
|
|
|
|
|
|
|
|
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let mcp_path = project_dir.join(".mcp.json");
|
|
|
|
|
assert!(mcp_path.exists(), "open_project should write .mcp.json for new projects");
|
|
|
|
|
let content = fs::read_to_string(&mcp_path).unwrap();
|
|
|
|
|
assert!(content.contains("3001"), "mcp.json should reference the server port");
|
|
|
|
|
assert!(content.contains("localhost"), "mcp.json should reference localhost");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 16:12:52 +01:00
|
|
|
/// Regression test for bug 371: no-arg `huskies` in empty directory skips scaffold.
|
|
|
|
|
/// `open_project` on a directory without `.huskies/` must create all required scaffold
|
|
|
|
|
/// files — the same files that `huskies .` produces.
|
2026-03-27 16:05:42 +00:00
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_project_on_empty_dir_creates_full_scaffold() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let project_dir = dir.path().join("myproject");
|
|
|
|
|
fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = SessionState::default();
|
|
|
|
|
|
|
|
|
|
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(
|
2026-04-03 16:12:52 +01:00
|
|
|
project_dir.join(".huskies/project.toml").exists(),
|
|
|
|
|
"open_project must create .huskies/project.toml"
|
2026-03-27 16:05:42 +00:00
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
project_dir.join(".mcp.json").exists(),
|
|
|
|
|
"open_project must create .mcp.json"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
project_dir.join("CLAUDE.md").exists(),
|
|
|
|
|
"open_project must create CLAUDE.md"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
project_dir.join("script/test").exists(),
|
|
|
|
|
"open_project must create script/test"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn close_project_clears_root() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let project_dir = dir.path().join("myproject");
|
|
|
|
|
fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = make_state_with_root(project_dir);
|
|
|
|
|
|
|
|
|
|
close_project(&state, &store).unwrap();
|
|
|
|
|
|
|
|
|
|
let root = state.project_root.lock().unwrap();
|
|
|
|
|
assert!(root.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn get_current_project_returns_none_when_no_project() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = SessionState::default();
|
|
|
|
|
|
|
|
|
|
let result = get_current_project(&state, &store).unwrap();
|
|
|
|
|
assert!(result.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn get_current_project_returns_active_root() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = make_state_with_root(dir.path().to_path_buf());
|
|
|
|
|
|
|
|
|
|
let result = get_current_project(&state, &store).unwrap();
|
|
|
|
|
assert!(result.is_some());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- known projects ---
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn known_projects_empty_by_default() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let projects = get_known_projects(&store).unwrap();
|
|
|
|
|
assert!(projects.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_project_adds_to_known_projects() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let project_dir = dir.path().join("proj1");
|
|
|
|
|
fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = SessionState::default();
|
|
|
|
|
|
|
|
|
|
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let projects = get_known_projects(&store).unwrap();
|
|
|
|
|
assert_eq!(projects.len(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn forget_known_project_removes_it() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
|
|
|
|
|
store.set(KEY_KNOWN_PROJECTS, json!(["/a", "/b", "/c"]));
|
|
|
|
|
forget_known_project("/b".to_string(), &store).unwrap();
|
|
|
|
|
|
|
|
|
|
let projects = get_known_projects(&store).unwrap();
|
|
|
|
|
assert_eq!(projects, vec!["/a", "/c"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn forget_unknown_project_is_noop() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
|
|
|
|
|
store.set(KEY_KNOWN_PROJECTS, json!(["/a"]));
|
|
|
|
|
forget_known_project("/nonexistent".to_string(), &store).unwrap();
|
|
|
|
|
|
|
|
|
|
let projects = get_known_projects(&store).unwrap();
|
|
|
|
|
assert_eq!(projects, vec!["/a"]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- validate_project_path ---
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn validate_project_path_rejects_missing() {
|
|
|
|
|
let result = validate_project_path(PathBuf::from("/nonexistent/path")).await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn validate_project_path_rejects_file() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let file = dir.path().join("not_a_dir.txt");
|
|
|
|
|
fs::write(&file, "").unwrap();
|
|
|
|
|
|
|
|
|
|
let result = validate_project_path(file).await;
|
|
|
|
|
assert!(result.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn validate_project_path_accepts_directory() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let result = validate_project_path(dir.path().to_path_buf()).await;
|
|
|
|
|
assert!(result.is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- open_project scaffolding ---
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_project_scaffolds_when_story_kit_missing() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let project_dir = dir.path().join("myproject");
|
|
|
|
|
fs::create_dir_all(&project_dir).unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = SessionState::default();
|
|
|
|
|
|
|
|
|
|
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
2026-04-03 16:12:52 +01:00
|
|
|
// .huskies/ should have been created automatically
|
|
|
|
|
assert!(project_dir.join(".huskies").is_dir());
|
2026-03-27 16:05:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn open_project_does_not_overwrite_existing_story_kit() {
|
|
|
|
|
let dir = tempdir().unwrap();
|
|
|
|
|
let project_dir = dir.path().join("myproject");
|
2026-04-03 16:12:52 +01:00
|
|
|
let sk_dir = project_dir.join(".huskies");
|
2026-03-27 16:05:42 +00:00
|
|
|
fs::create_dir_all(&sk_dir).unwrap();
|
|
|
|
|
let readme = sk_dir.join("README.md");
|
|
|
|
|
fs::write(&readme, "custom content").unwrap();
|
|
|
|
|
let store = make_store(&dir);
|
|
|
|
|
let state = SessionState::default();
|
|
|
|
|
|
|
|
|
|
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
2026-04-03 16:12:52 +01:00
|
|
|
// Existing .huskies/ content should not be overwritten
|
2026-03-27 16:05:42 +00:00
|
|
|
assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
|
|
|
|
|
}
|
|
|
|
|
}
|