diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index c3636e7..b746bcc 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -25,8 +25,31 @@ use poem::{Route, get, post}; use poem_openapi::OpenApiService; use project::ProjectApi; use settings::SettingsApi; +use std::path::{Path, PathBuf}; use std::sync::Arc; +const DEFAULT_PORT: u16 = 3001; + +pub fn parse_port(value: Option) -> u16 { + value + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_PORT) +} + +pub fn resolve_port() -> u16 { + parse_port(std::env::var("STORYKIT_PORT").ok()) +} + +pub fn write_port_file(dir: &Path, port: u16) -> Option { + let path = dir.join(".story_kit_port"); + std::fs::write(&path, port.to_string()).ok()?; + Some(path) +} + +pub fn remove_port_file(path: &Path) { + let _ = std::fs::remove_file(path); +} + pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint { let ctx_arc = std::sync::Arc::new(ctx); @@ -93,3 +116,34 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) { (api_service, docs_service) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_port_defaults_to_3001() { + assert_eq!(parse_port(None), 3001); + } + + #[test] + fn parse_port_reads_valid_value() { + assert_eq!(parse_port(Some("4200".to_string())), 4200); + } + + #[test] + fn parse_port_ignores_invalid_value() { + assert_eq!(parse_port(Some("not_a_number".to_string())), 3001); + } + + #[test] + fn write_and_remove_port_file() { + let tmp = tempfile::tempdir().unwrap(); + + let path = write_port_file(tmp.path(), 4567).expect("should write port file"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "4567"); + + remove_port_file(&path); + assert!(!path.exists()); + } +} diff --git a/server/src/io/fs.rs b/server/src/io/fs.rs index ca630e9..df0d49f 100644 --- a/server/src/io/fs.rs +++ b/server/src/io/fs.rs @@ -379,6 +379,20 @@ To support both Remote and Local models, the system implements a `ModelProvider` * Shell commands that modify state (non-readonly) should ideally require a UI confirmation (configurable). * File writes must be confirmed or revertible."#; +/// Walk from `start` up through parent directories, returning the first +/// directory that contains a `.story_kit/` subdirectory, or `None`. +pub fn find_story_kit_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if current.join(".story_kit").is_dir() { + return Some(current); + } + if !current.pop() { + return None; + } + } +} + pub fn get_home_directory() -> Result { let home = homedir::my_home() .map_err(|e| format!("Failed to resolve home directory: {e}"))? @@ -960,6 +974,47 @@ mod tests { assert!(result.is_ok()); } + // --- 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(".story_kit")).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(".story_kit")).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(".story_kit")).unwrap(); + let child = tmp.path().join("inner"); + std::fs::create_dir_all(child.join(".story_kit")).unwrap(); + + let result = find_story_kit_root(&child); + assert_eq!(result, Some(child)); + } + // --- scaffold --- #[test] diff --git a/server/src/main.rs b/server/src/main.rs index 3a5f4d9..120d331 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -11,51 +11,17 @@ mod worktree; use crate::agents::AgentPool; use crate::http::build_routes; use crate::http::context::AppContext; +use crate::http::{remove_port_file, resolve_port, write_port_file}; +use crate::io::fs::find_story_kit_root; use crate::state::SessionState; use crate::store::JsonFileStore; use crate::workflow::WorkflowState; use poem::Server; use poem::listener::TcpListener; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::broadcast; -const DEFAULT_PORT: u16 = 3001; - -fn parse_port(value: Option) -> u16 { - value - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_PORT) -} - -fn resolve_port() -> u16 { - parse_port(std::env::var("STORYKIT_PORT").ok()) -} - -fn write_port_file(dir: &Path, port: u16) -> Option { - let path = dir.join(".story_kit_port"); - std::fs::write(&path, port.to_string()).ok()?; - Some(path) -} - -fn remove_port_file(path: &Path) { - let _ = std::fs::remove_file(path); -} - -/// Walk from `start` up through parent directories, returning the first -/// directory that contains a `.story_kit/` subdirectory, or `None`. -fn find_story_kit_root(start: &Path) -> Option { - let mut current = start.to_path_buf(); - loop { - if current.join(".story_kit").is_dir() { - return Some(current); - } - if !current.pop() { - return None; - } - } -} - #[tokio::main] async fn main() -> Result<(), std::io::Error> { let app_state = Arc::new(SessionState::default()); @@ -131,21 +97,6 @@ async fn main() -> Result<(), std::io::Error> { mod tests { use super::*; - #[test] - fn parse_port_defaults_to_3001() { - assert_eq!(parse_port(None), 3001); - } - - #[test] - fn parse_port_reads_valid_value() { - assert_eq!(parse_port(Some("4200".to_string())), 4200); - } - - #[test] - fn parse_port_ignores_invalid_value() { - assert_eq!(parse_port(Some("not_a_number".to_string())), 3001); - } - #[test] #[should_panic(expected = "Invalid project.toml: Duplicate agent name")] fn panics_on_duplicate_agent_names() { @@ -167,56 +118,4 @@ name = "coder" config::ProjectConfig::load(tmp.path()) .unwrap_or_else(|e| panic!("Invalid project.toml: {e}")); } - - #[test] - fn write_and_remove_port_file() { - let tmp = tempfile::tempdir().unwrap(); - - let path = write_port_file(tmp.path(), 4567).expect("should write port file"); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "4567"); - - remove_port_file(&path); - assert!(!path.exists()); - } - - #[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(".story_kit")).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(".story_kit")).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(); - // No .story_kit/ created - - let result = find_story_kit_root(tmp.path()); - assert_eq!(result, None); - } - - #[test] - fn find_story_kit_root_prefers_nearest_ancestor() { - // If both cwd and a parent have .story_kit/, return cwd (nearest). - let tmp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(tmp.path().join(".story_kit")).unwrap(); - let child = tmp.path().join("inner"); - std::fs::create_dir_all(child.join(".story_kit")).unwrap(); - - let result = find_story_kit_root(&child); - assert_eq!(result, Some(child)); - } }