foundry_mcp/cli/commands/
load_project.rs1use crate::cli::args::LoadProjectArgs;
4use crate::core::{filesystem, project};
5use crate::types::responses::{FoundryResponse, LoadProjectResponse, ProjectContext};
6
7use anyhow::{Context, Result};
8use std::fs;
9
10pub async fn execute(args: LoadProjectArgs) -> Result<FoundryResponse<LoadProjectResponse>> {
11 validate_project_exists(&args.project_name)?;
13
14 let project_path = project::get_project_path(&args.project_name)?;
16 let project_context = load_project_context(&args.project_name, &project_path)?;
17 let specs_available = project_context.specs_available.clone();
18
19 let response_data = LoadProjectResponse {
21 project: project_context,
22 };
23
24 let validation_status = if specs_available.is_empty() {
26 crate::types::responses::ValidationStatus::Incomplete
27 } else {
28 crate::types::responses::ValidationStatus::Complete
29 };
30
31 Ok(crate::types::responses::FoundryResponse {
32 data: response_data,
33 next_steps: generate_next_steps(&args.project_name, &specs_available),
34 validation_status,
35 workflow_hints: generate_workflow_hints(&specs_available),
36 })
37}
38
39fn validate_project_exists(project_name: &str) -> Result<()> {
41 if !project::project_exists(project_name)? {
42 return Err(anyhow::anyhow!(
43 "Project '{}' not found. Use 'foundry list-projects' to see available projects.",
44 project_name
45 ));
46 }
47 Ok(())
48}
49
50fn load_project_context(
52 project_name: &str,
53 project_path: &std::path::Path,
54) -> Result<ProjectContext> {
55 let vision =
57 filesystem::read_file(project_path.join("vision.md")).unwrap_or_else(|_| String::new());
58 let tech_stack =
59 filesystem::read_file(project_path.join("tech-stack.md")).unwrap_or_else(|_| String::new());
60 let summary =
61 filesystem::read_file(project_path.join("summary.md")).unwrap_or_else(|_| String::new());
62
63 let created_at = fs::metadata(project_path)
65 .and_then(|metadata| metadata.created())
66 .map_err(anyhow::Error::from)
67 .and_then(|time| {
68 time.duration_since(std::time::UNIX_EPOCH)
69 .map_err(anyhow::Error::from)
70 })
71 .map(|duration| {
72 chrono::DateTime::from_timestamp(duration.as_secs() as i64, 0)
73 .unwrap_or_else(chrono::Utc::now)
74 .to_rfc3339()
75 })
76 .unwrap_or_else(|_| chrono::Utc::now().to_rfc3339());
77
78 let specs_dir = project_path.join("specs");
80 let specs_available = if specs_dir.exists() {
81 fs::read_dir(&specs_dir)
82 .context("Failed to read specs directory")?
83 .filter_map(|entry| {
84 entry
85 .ok()
86 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
87 .map(|e| e.file_name().to_string_lossy().to_string())
88 })
89 .collect()
90 } else {
91 Vec::new()
92 };
93
94 Ok(ProjectContext {
95 name: project_name.to_string(),
96 vision,
97 tech_stack,
98 summary,
99 specs_available,
100 created_at,
101 })
102}
103
104fn generate_next_steps(project_name: &str, specs_available: &[String]) -> Vec<String> {
106 if specs_available.is_empty() {
107 vec![
108 "Project context loaded successfully - ready for specification creation".to_string(),
109 format!(
110 "You can create your first specification: foundry create-spec {} <feature_name>",
111 project_name
112 ),
113 "Your loaded project context provides comprehensive background for development decisions".to_string(),
114 ]
115 } else {
116 vec![
117 format!(
118 "Project context loaded with {} specification(s) available",
119 specs_available.len()
120 ),
121 format!(
122 "You can load a specific spec: foundry load-spec {} <spec_name>",
123 project_name
124 ),
125 format!(
126 "You can create a new spec: foundry create-spec {} <feature_name>",
127 project_name
128 ),
129 ]
130 }
131}
132
133fn generate_workflow_hints(specs_available: &[String]) -> Vec<String> {
135 let mut hints = vec![
136 "You can use the project summary for quick context in conversations".to_string(),
137 "The full vision provides comprehensive background and goals for your work".to_string(),
138 "Tech stack details guide your implementation decisions and technology choices".to_string(),
139 "You can skip list-projects calls when you know the project name - load_project is more efficient".to_string(),
140 ];
141
142 if specs_available.is_empty() {
143 hints.push(
144 "You can create specifications to track specific features as you identify them"
145 .to_string(),
146 );
147 hints.push(
148 "You can prompt the user about creating specifications to track specific features"
149 .to_string(),
150 );
151 } else {
152 hints.push(format!("Available specs: {}", specs_available.join(", ")));
153 hints.push(
154 "You can load individual specs to see detailed implementation plans and progress"
155 .to_string(),
156 );
157 hints.push("You can update existing specs with progress as work continues".to_string());
158 }
159
160 hints.push(
161 "You can use foundry get_foundry_help decision-points to understand tool selection"
162 .to_string(),
163 );
164
165 hints
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::core::filesystem;
172 use tempfile::TempDir;
173
174 fn create_test_args(project_name: &str) -> LoadProjectArgs {
175 LoadProjectArgs {
176 project_name: project_name.to_string(),
177 }
178 }
179
180 #[test]
181 fn test_validate_project_exists_missing_project() {
182 let result = validate_project_exists("non-existent-project-12345");
183
184 assert!(result.is_err());
185 let error_message = result.unwrap_err().to_string();
186 assert!(error_message.contains("not found"));
187 assert!(error_message.contains("list-projects"));
188 }
189
190 #[test]
191 fn test_generate_next_steps_no_specs() {
192 let project_name = "test-project";
193 let specs_available = Vec::<String>::new();
194 let steps = generate_next_steps(project_name, &specs_available);
195
196 assert_eq!(steps.len(), 3);
197 assert!(steps[0].contains("ready for specification creation"));
198 assert!(steps[1].contains("foundry create-spec"));
199 assert!(steps[1].contains(project_name));
200 assert!(steps[2].contains("comprehensive background"));
201 }
202
203 #[test]
204 fn test_generate_next_steps_with_specs() {
205 let project_name = "test-project";
206 let specs_available = vec![
207 "20240824_120000_feature1".to_string(),
208 "20240824_130000_feature2".to_string(),
209 ];
210 let steps = generate_next_steps(project_name, &specs_available);
211
212 assert_eq!(steps.len(), 3);
213 assert!(steps[0].contains("loaded with 2 specification"));
214 assert!(steps[1].contains("foundry load-spec"));
215 assert!(steps[1].contains(project_name));
216 assert!(steps[2].contains("foundry create-spec"));
217 assert!(steps[2].contains(project_name));
218 }
219
220 #[test]
221 fn test_generate_workflow_hints_no_specs() {
222 let specs_available = Vec::<String>::new();
223 let hints = generate_workflow_hints(&specs_available);
224
225 assert!(hints.len() >= 6);
226 assert!(hints.iter().any(|h| h.contains("project summary")));
227 assert!(hints.iter().any(|h| h.contains("vision provides")));
228 assert!(hints.iter().any(|h| h.contains("Tech stack")));
229 assert!(hints.iter().any(|h| h.contains("create specifications")));
230 assert!(!hints.iter().any(|h| h.contains("Available specs")));
232 assert!(!hints.iter().any(|h| h.contains("Load individual specs")));
233 }
234
235 #[test]
236 fn test_generate_workflow_hints_with_specs() {
237 let specs_available = vec![
238 "20240824_120000_feature1".to_string(),
239 "20240824_130000_feature2".to_string(),
240 ];
241 let hints = generate_workflow_hints(&specs_available);
242
243 assert!(hints.len() >= 6);
244 assert!(hints.iter().any(|h| h.contains("project summary")));
245 assert!(hints.iter().any(|h| h.contains("vision provides")));
246 assert!(hints.iter().any(|h| h.contains("Tech stack")));
247 assert!(hints.iter().any(|h| h.contains("Available specs")));
248 assert!(hints.iter().any(|h| h.contains("feature1")));
249 assert!(hints.iter().any(|h| h.contains("feature2")));
250 assert!(hints.iter().any(|h| h.contains("load individual specs")));
251 assert!(!hints.iter().any(|h| h.contains("create specifications")));
253 }
254
255 #[test]
256 fn test_execute_with_missing_project() {
257 use crate::test_utils::TestEnvironment;
258 let env = TestEnvironment::new().unwrap();
259
260 env.with_env_async(|| async {
261 let args = create_test_args("non-existent-project");
262 let result = execute(args).await;
263
264 assert!(result.is_err());
265 let error_message = result.unwrap_err().to_string();
266 assert!(error_message.contains("not found"));
267 });
268 }
269
270 #[test]
271 fn test_load_project_context_missing_files() {
272 let temp_dir = TempDir::new().unwrap();
273 let project_name = "test-project-incomplete";
274 let project_path = temp_dir.path();
275
276 filesystem::create_dir_all(project_path).unwrap();
278 filesystem::create_dir_all(project_path.join("specs")).unwrap();
279
280 let context = load_project_context(project_name, project_path).unwrap();
281
282 assert_eq!(context.name, project_name);
283 assert!(context.vision.is_empty());
285 assert!(context.tech_stack.is_empty());
286 assert!(context.summary.is_empty());
287 assert!(context.specs_available.is_empty());
288 assert!(!context.created_at.is_empty()); }
290
291 #[test]
292 fn test_load_project_context_with_specs() {
293 let temp_dir = TempDir::new().unwrap();
294 let project_name = "test-project-with-specs";
295 let project_path = temp_dir.path();
296 let specs_dir = project_path.join("specs");
297
298 filesystem::create_dir_all(&specs_dir).unwrap();
300
301 let spec1_dir = specs_dir.join("20240824_120000_feature1");
303 let spec2_dir = specs_dir.join("20240824_130000_feature2");
304 filesystem::create_dir_all(&spec1_dir).unwrap();
305 filesystem::create_dir_all(&spec2_dir).unwrap();
306
307 let context = load_project_context(project_name, project_path).unwrap();
308
309 assert_eq!(context.name, project_name);
310 assert_eq!(context.specs_available.len(), 2);
311 assert!(
312 context
313 .specs_available
314 .contains(&"20240824_120000_feature1".to_string())
315 );
316 assert!(
317 context
318 .specs_available
319 .contains(&"20240824_130000_feature2".to_string())
320 );
321 }
322}