storkit: merge 416_refactor_split_io_fs_rs_into_submodules
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
use crate::state::SessionState;
|
||||
use serde::Serialize;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::paths::{resolve_path, resolve_path_impl};
|
||||
|
||||
async fn read_file_impl(full_path: PathBuf) -> Result<String, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
fs::read_to_string(&full_path).map_err(|e| format!("Failed to read file: {}", e))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {}", e))?
|
||||
}
|
||||
|
||||
pub async fn read_file(path: String, state: &SessionState) -> Result<String, String> {
|
||||
let full_path = resolve_path(state, &path)?;
|
||||
read_file_impl(full_path).await
|
||||
}
|
||||
|
||||
async fn write_file_impl(full_path: PathBuf, content: String) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Some(parent) = full_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directories: {}", e))?;
|
||||
}
|
||||
|
||||
fs::write(&full_path, content).map_err(|e| format!("Failed to write file: {}", e))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {}", e))?
|
||||
}
|
||||
|
||||
pub async fn write_file(path: String, content: String, state: &SessionState) -> Result<(), String> {
|
||||
let root = state.get_project_root()?;
|
||||
let full_path = resolve_path_impl(root, &path)?;
|
||||
write_file_impl(full_path, content).await
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, poem_openapi::Object)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
async fn list_directory_impl(full_path: PathBuf) -> Result<Vec<FileEntry>, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let entries = fs::read_dir(&full_path).map_err(|e| format!("Failed to read dir: {}", e))?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let ft = entry.file_type().map_err(|e| e.to_string())?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
result.push(FileEntry {
|
||||
name,
|
||||
kind: if ft.is_dir() {
|
||||
"dir".to_string()
|
||||
} else {
|
||||
"file".to_string()
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
result.sort_by(|a, b| match (a.kind.as_str(), b.kind.as_str()) {
|
||||
("dir", "file") => std::cmp::Ordering::Less,
|
||||
("file", "dir") => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {}", e))?
|
||||
}
|
||||
|
||||
pub async fn list_directory(path: String, state: &SessionState) -> Result<Vec<FileEntry>, String> {
|
||||
let full_path = resolve_path(state, &path)?;
|
||||
list_directory_impl(full_path).await
|
||||
}
|
||||
|
||||
pub async fn list_directory_absolute(path: String) -> Result<Vec<FileEntry>, String> {
|
||||
let full_path = PathBuf::from(path);
|
||||
list_directory_impl(full_path).await
|
||||
}
|
||||
|
||||
pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
|
||||
let full_path = PathBuf::from(path);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
fs::create_dir_all(&full_path).map_err(|e| format!("Failed to create directory: {}", e))?;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {}", e))?
|
||||
}
|
||||
|
||||
/// List all files in the project recursively, respecting .gitignore.
|
||||
/// Returns relative paths from the project root (files only, not directories).
|
||||
pub async fn list_project_files(state: &SessionState) -> Result<Vec<String>, String> {
|
||||
let root = state.get_project_root()?;
|
||||
list_project_files_impl(root).await
|
||||
}
|
||||
|
||||
pub async fn list_project_files_impl(root: PathBuf) -> Result<Vec<String>, String> {
|
||||
use ignore::WalkBuilder;
|
||||
|
||||
let root_clone = root.clone();
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
let mut result = Vec::new();
|
||||
let walker = WalkBuilder::new(&root_clone).git_ignore(true).build();
|
||||
|
||||
for entry in walker.flatten() {
|
||||
if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
|
||||
let relative = entry
|
||||
.path()
|
||||
.strip_prefix(&root_clone)
|
||||
.unwrap_or(entry.path())
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
result.push(relative);
|
||||
}
|
||||
}
|
||||
|
||||
result.sort();
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {e}"))?;
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::state::SessionState;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn make_state_with_root(path: PathBuf) -> SessionState {
|
||||
let state = SessionState::default();
|
||||
{
|
||||
let mut root = state.project_root.lock().unwrap();
|
||||
*root = Some(path);
|
||||
}
|
||||
state
|
||||
}
|
||||
|
||||
// --- file operations ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_impl_reads_content() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("test.txt");
|
||||
fs::write(&file, "hello world").unwrap();
|
||||
|
||||
let content = read_file_impl(file).await.unwrap();
|
||||
assert_eq!(content, "hello world");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_file_impl_errors_on_missing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let result = read_file_impl(dir.path().join("missing.txt")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_file_impl_creates_and_writes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file = dir.path().join("sub").join("output.txt");
|
||||
|
||||
write_file_impl(file.clone(), "content".to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(fs::read_to_string(&file).unwrap(), "content");
|
||||
}
|
||||
|
||||
// --- list directory ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_directory_impl_returns_sorted_entries() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("zdir")).unwrap();
|
||||
fs::create_dir(dir.path().join("adir")).unwrap();
|
||||
fs::write(dir.path().join("file.txt"), "").unwrap();
|
||||
|
||||
let entries = list_directory_impl(dir.path().to_path_buf()).await.unwrap();
|
||||
|
||||
assert_eq!(entries[0].name, "adir");
|
||||
assert_eq!(entries[0].kind, "dir");
|
||||
assert_eq!(entries[1].name, "zdir");
|
||||
assert_eq!(entries[1].kind, "dir");
|
||||
assert_eq!(entries[2].name, "file.txt");
|
||||
assert_eq!(entries[2].kind, "file");
|
||||
}
|
||||
|
||||
// --- list_project_files_impl ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_project_files_returns_all_files() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("src")).unwrap();
|
||||
fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
|
||||
fs::write(dir.path().join("README.md"), "# readme").unwrap();
|
||||
|
||||
let files = list_project_files_impl(dir.path().to_path_buf())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(files.contains(&"README.md".to_string()));
|
||||
assert!(files.contains(&"src/main.rs".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_project_files_excludes_dirs_from_output() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("subdir")).unwrap();
|
||||
fs::write(dir.path().join("file.txt"), "").unwrap();
|
||||
|
||||
let files = list_project_files_impl(dir.path().to_path_buf())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(files.contains(&"file.txt".to_string()));
|
||||
assert!(!files.iter().any(|f| f == "subdir"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_project_files_returns_sorted() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("z.txt"), "").unwrap();
|
||||
fs::write(dir.path().join("a.txt"), "").unwrap();
|
||||
|
||||
let files = list_project_files_impl(dir.path().to_path_buf())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let a_idx = files.iter().position(|f| f == "a.txt").unwrap();
|
||||
let z_idx = files.iter().position(|f| f == "z.txt").unwrap();
|
||||
assert!(a_idx < z_idx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_project_files_with_state() {
|
||||
let dir = tempdir().unwrap();
|
||||
fs::write(dir.path().join("hello.rs"), "").unwrap();
|
||||
let state = make_state_with_root(dir.path().to_path_buf());
|
||||
|
||||
let files = list_project_files(&state).await.unwrap();
|
||||
|
||||
assert!(files.contains(&"hello.rs".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_project_files_errors_without_project() {
|
||||
let state = SessionState::default();
|
||||
let result = list_project_files(&state).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
pub mod files;
|
||||
pub mod paths;
|
||||
pub mod preferences;
|
||||
pub mod project;
|
||||
pub mod scaffold;
|
||||
|
||||
pub use files::{
|
||||
create_directory_absolute, list_directory, list_directory_absolute, list_project_files,
|
||||
read_file, write_file, FileEntry,
|
||||
};
|
||||
pub use paths::{find_story_kit_root, get_home_directory, resolve_cli_path};
|
||||
pub use preferences::{get_model_preference, set_model_preference};
|
||||
pub use project::{
|
||||
close_project, forget_known_project, get_current_project, get_known_projects, open_project,
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
use crate::state::SessionState;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Resolve a path argument supplied on the CLI against the given working
|
||||
/// directory. Relative paths (including `.`) are joined with `cwd` and
|
||||
/// then canonicalized when possible. Absolute paths are returned
|
||||
/// canonicalized when possible, unchanged otherwise.
|
||||
pub fn resolve_cli_path(cwd: &Path, path_arg: &str) -> PathBuf {
|
||||
let p = PathBuf::from(path_arg);
|
||||
let joined = if p.is_absolute() { p } else { cwd.join(p) };
|
||||
// Canonicalize resolves `.`, `..` and symlinks. We fall back to the
|
||||
// joined (non-canonical) path when the target does not yet exist so
|
||||
// that callers can still create it later.
|
||||
std::fs::canonicalize(&joined).unwrap_or(joined)
|
||||
}
|
||||
|
||||
/// Walk from `start` up through parent directories, returning the first
|
||||
/// directory that contains a `.storkit/` subdirectory, or `None`.
|
||||
pub fn find_story_kit_root(start: &Path) -> Option<PathBuf> {
|
||||
let mut current = start.to_path_buf();
|
||||
loop {
|
||||
if current.join(".storkit").is_dir() {
|
||||
return Some(current);
|
||||
}
|
||||
if !current.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_home_directory() -> Result<String, String> {
|
||||
let home = homedir::my_home()
|
||||
.map_err(|e| format!("Failed to resolve home directory: {e}"))?
|
||||
.ok_or_else(|| "Home directory not found".to_string())?;
|
||||
Ok(home.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Resolves a relative path against the active project root (pure function for testing).
|
||||
/// Returns error if path attempts traversal (..).
|
||||
pub(crate) fn resolve_path_impl(root: PathBuf, relative_path: &str) -> Result<PathBuf, String> {
|
||||
if relative_path.contains("..") {
|
||||
return Err("Security Violation: Directory traversal ('..') is not allowed.".to_string());
|
||||
}
|
||||
|
||||
Ok(root.join(relative_path))
|
||||
}
|
||||
|
||||
/// Resolves a relative path against the active project root.
|
||||
/// Returns error if no project is open or if path attempts traversal (..).
|
||||
pub(crate) fn resolve_path(state: &SessionState, relative_path: &str) -> Result<PathBuf, String> {
|
||||
let root = state.get_project_root()?;
|
||||
resolve_path_impl(root, relative_path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
// --- resolve_path_impl ---
|
||||
|
||||
#[test]
|
||||
fn resolve_path_joins_relative_to_root() {
|
||||
let root = PathBuf::from("/projects/myapp");
|
||||
let result = resolve_path_impl(root, "src/main.rs").unwrap();
|
||||
assert_eq!(result, PathBuf::from("/projects/myapp/src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_path_rejects_traversal() {
|
||||
let root = PathBuf::from("/projects/myapp");
|
||||
let result = resolve_path_impl(root, "../etc/passwd");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("traversal"));
|
||||
}
|
||||
|
||||
// --- find_story_kit_root ---
|
||||
|
||||
#[test]
|
||||
fn find_story_kit_root_returns_cwd_when_story_kit_in_cwd() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap();
|
||||
|
||||
let result = find_story_kit_root(tmp.path());
|
||||
assert_eq!(result, Some(tmp.path().to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_story_kit_root_returns_parent_when_story_kit_in_parent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap();
|
||||
let child = tmp.path().join("subdir").join("nested");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
|
||||
let result = find_story_kit_root(&child);
|
||||
assert_eq!(result, Some(tmp.path().to_path_buf()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_story_kit_root_returns_none_when_no_story_kit() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
let result = find_story_kit_root(tmp.path());
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_story_kit_root_prefers_nearest_ancestor() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join(".storkit")).unwrap();
|
||||
let child = tmp.path().join("inner");
|
||||
std::fs::create_dir_all(child.join(".storkit")).unwrap();
|
||||
|
||||
let result = find_story_kit_root(&child);
|
||||
assert_eq!(result, Some(child));
|
||||
}
|
||||
|
||||
// --- resolve_cli_path ---
|
||||
|
||||
#[test]
|
||||
fn resolve_cli_path_absolute_returned_unchanged_when_nonexistent() {
|
||||
let cwd = PathBuf::from("/some/cwd");
|
||||
let result = resolve_cli_path(&cwd, "/nonexistent/absolute/path");
|
||||
assert_eq!(result, PathBuf::from("/nonexistent/absolute/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_cli_path_dot_resolves_to_cwd() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let cwd = tmp.path().to_path_buf();
|
||||
let result = resolve_cli_path(&cwd, ".");
|
||||
// Canonicalize should resolve "." in an existing dir to the canonical cwd
|
||||
assert_eq!(result, cwd.canonicalize().unwrap_or(cwd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_cli_path_relative_resolves_against_cwd() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let cwd = tmp.path().to_path_buf();
|
||||
let subdir = cwd.join("sub");
|
||||
std::fs::create_dir_all(&subdir).unwrap();
|
||||
let result = resolve_cli_path(&cwd, "sub");
|
||||
assert_eq!(result, subdir.canonicalize().unwrap_or(subdir));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_cli_path_nonexistent_relative_falls_back_to_joined() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let cwd = tmp.path().to_path_buf();
|
||||
let result = resolve_cli_path(&cwd, "newproject");
|
||||
// Path doesn't exist yet — canonicalize fails, fallback is cwd/newproject
|
||||
assert_eq!(result, cwd.join("newproject"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use crate::store::StoreOps;
|
||||
use serde_json::json;
|
||||
|
||||
const KEY_SELECTED_MODEL: &str = "selected_model";
|
||||
|
||||
pub fn get_model_preference(store: &dyn StoreOps) -> Result<Option<String>, String> {
|
||||
if let Some(model) = store
|
||||
.get(KEY_SELECTED_MODEL)
|
||||
.as_ref()
|
||||
.and_then(|val| val.as_str())
|
||||
{
|
||||
return Ok(Some(model.to_string()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn set_model_preference(model: String, store: &dyn StoreOps) -> Result<(), String> {
|
||||
store.set(KEY_SELECTED_MODEL, json!(model));
|
||||
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()
|
||||
}
|
||||
|
||||
// --- model preference ---
|
||||
|
||||
#[test]
|
||||
fn model_preference_none_by_default() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = make_store(&dir);
|
||||
assert!(get_model_preference(&store).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_get_model_preference() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = make_store(&dir);
|
||||
set_model_preference("claude-3-sonnet".to_string(), &store).unwrap();
|
||||
assert_eq!(
|
||||
get_model_preference(&store).unwrap(),
|
||||
Some("claude-3-sonnet".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
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))?;
|
||||
}
|
||||
if !path.join(".storkit").is_dir() {
|
||||
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");
|
||||
}
|
||||
|
||||
/// Regression test for bug 371: no-arg `storkit` in empty directory skips scaffold.
|
||||
/// `open_project` on a directory without `.storkit/` must create all required scaffold
|
||||
/// files — the same files that `storkit .` produces.
|
||||
#[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!(
|
||||
project_dir.join(".storkit/project.toml").exists(),
|
||||
"open_project must create .storkit/project.toml"
|
||||
);
|
||||
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();
|
||||
|
||||
// .storkit/ should have been created automatically
|
||||
assert!(project_dir.join(".storkit").is_dir());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_project_does_not_overwrite_existing_story_kit() {
|
||||
let dir = tempdir().unwrap();
|
||||
let project_dir = dir.path().join("myproject");
|
||||
let sk_dir = project_dir.join(".storkit");
|
||||
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();
|
||||
|
||||
// Existing .storkit/ content should not be overwritten
|
||||
assert_eq!(fs::read_to_string(&readme).unwrap(), "custom content");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user