1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct ProjectSpec {
7 pub name: String,
8 #[serde(skip_serializing_if = "Option::is_none")]
9 pub analyzed: Option<String>,
10 pub files_analyzed: usize,
11 pub capabilities: Vec<Capability>,
12 #[serde(skip_serializing_if = "Vec::is_empty", default)]
13 pub dependencies: Vec<DependencyEdge>,
14 #[serde(skip_serializing_if = "Vec::is_empty", default)]
15 pub domains: Vec<Domain>,
16 #[serde(skip_serializing_if = "Vec::is_empty", default)]
17 pub flows: Vec<RequestFlow>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Capability {
23 pub name: String,
24 pub source: String,
25 #[serde(skip_serializing_if = "Vec::is_empty", default)]
26 pub endpoints: Vec<Endpoint>,
27 #[serde(skip_serializing_if = "Vec::is_empty", default)]
28 pub operations: Vec<Operation>,
29 #[serde(skip_serializing_if = "Vec::is_empty", default)]
30 pub entities: Vec<Entity>,
31 #[serde(skip_serializing_if = "Vec::is_empty", default)]
32 pub scheduled_tasks: Vec<ScheduledTask>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Endpoint {
38 pub method: HttpMethod,
39 pub path: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub input: Option<EndpointInput>,
42 #[serde(skip_serializing_if = "Vec::is_empty", default)]
43 pub validation: Vec<ValidationRule>,
44 #[serde(skip_serializing_if = "Vec::is_empty", default)]
45 pub behaviors: Vec<Behavior>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub security: Option<SecurityConfig>,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "UPPERCASE")]
52pub enum HttpMethod {
53 Get,
54 Post,
55 Put,
56 Delete,
57 Patch,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct EndpointInput {
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub body: Option<TypeRef>,
64 #[serde(skip_serializing_if = "Vec::is_empty", default)]
65 pub path_params: Vec<Param>,
66 #[serde(skip_serializing_if = "Vec::is_empty", default)]
67 pub query_params: Vec<Param>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Param {
72 pub name: String,
73 #[serde(rename = "type")]
74 pub param_type: String,
75 #[serde(skip_serializing_if = "std::ops::Not::not", default)]
76 pub required: bool,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct TypeRef {
82 pub name: String,
83 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
84 pub fields: BTreeMap<String, String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ValidationRule {
90 pub field: String,
91 pub constraints: Vec<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Behavior {
97 pub name: String,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub condition: Option<String>,
100 pub returns: ResponseSpec,
101 #[serde(skip_serializing_if = "Vec::is_empty", default)]
102 pub side_effects: Vec<SideEffect>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ResponseSpec {
107 pub status: u16,
108 #[serde(skip_serializing_if = "Option::is_none")]
109 pub body: Option<TypeRef>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(tag = "kind")]
114pub enum SideEffect {
115 #[serde(rename = "db_insert")]
116 DbInsert { table: String },
117 #[serde(rename = "db_update")]
118 DbUpdate { description: String },
119 #[serde(rename = "event")]
120 Event { name: String },
121 #[serde(rename = "call")]
122 ServiceCall { target: String },
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct SecurityConfig {
127 #[serde(skip_serializing_if = "Option::is_none")]
128 pub authentication: Option<String>,
129 #[serde(skip_serializing_if = "Vec::is_empty", default)]
130 pub roles: Vec<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub rate_limit: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub cors: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Operation {
140 pub name: String,
141 pub source_method: String,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub input: Option<TypeRef>,
144 #[serde(skip_serializing_if = "Vec::is_empty", default)]
145 pub behaviors: Vec<Behavior>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub transaction: Option<String>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct Entity {
153 pub name: String,
154 pub table: String,
155 pub fields: Vec<EntityField>,
156 #[serde(skip_serializing_if = "Vec::is_empty", default)]
158 pub bases: Vec<String>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct EntityField {
163 pub name: String,
164 #[serde(rename = "type")]
165 pub field_type: String,
166 #[serde(skip_serializing_if = "Vec::is_empty", default)]
167 pub constraints: Vec<String>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ScheduledTask {
173 pub name: String,
174 pub schedule: String,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 pub description: Option<String>,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct DependencyEdge {
182 pub from: String,
183 pub to: String,
184 pub kind: DependencyKind,
185 #[serde(skip_serializing_if = "Vec::is_empty", default)]
186 pub references: Vec<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "lowercase")]
192pub enum DependencyKind {
193 Calls,
194 Queries,
195 Listens,
196 Validates,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct Domain {
202 pub name: String,
203 pub capabilities: Vec<String>,
204 #[serde(skip_serializing_if = "Vec::is_empty", default)]
205 pub external_dependencies: Vec<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct RequestFlow {
211 pub trigger: String,
212 pub entry_point: String,
213 pub steps: Vec<FlowStep>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct FlowStep {
219 pub actor: String,
220 pub method: String,
221 pub kind: FlowStepKind,
222 pub description: String,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub condition: Option<String>,
225 #[serde(skip_serializing_if = "Vec::is_empty", default)]
226 pub children: Vec<FlowStep>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231#[serde(rename_all = "snake_case")]
232pub enum FlowStepKind {
233 ServiceCall,
234 DbRead,
235 DbWrite,
236 EventPublish,
237 Validation,
238 SecurityGuard,
239 Condition,
240 Return,
241 ThrowException,
242}
243
244impl ProjectSpec {
245 pub fn new(name: impl Into<String>) -> Self {
246 Self {
247 name: name.into(),
248 analyzed: Some(chrono_now()),
249 files_analyzed: 0,
250 capabilities: Vec::new(),
251 dependencies: Vec::new(),
252 domains: Vec::new(),
253 flows: Vec::new(),
254 }
255 }
256}
257
258fn chrono_now() -> String {
259 use std::time::SystemTime;
260 let duration = SystemTime::now()
261 .duration_since(SystemTime::UNIX_EPOCH)
262 .unwrap_or_default();
263 let secs = duration.as_secs();
264 let days = secs / 86400;
266 let time_secs = secs % 86400;
267 let hours = time_secs / 3600;
268 let minutes = (time_secs % 3600) / 60;
269 let seconds = time_secs % 60;
270 let (year, month, day) = days_to_ymd(days);
272 format!(
273 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
274 year, month, day, hours, minutes, seconds
275 )
276}
277
278fn days_to_ymd(mut days: u64) -> (u64, u64, u64) {
279 let mut year = 1970;
280 loop {
281 let days_in_year = if is_leap(year) { 366 } else { 365 };
282 if days < days_in_year {
283 break;
284 }
285 days -= days_in_year;
286 year += 1;
287 }
288 let month_days: &[u64] = if is_leap(year) {
289 &[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
290 } else {
291 &[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
292 };
293 let mut month = 1;
294 for &md in month_days {
295 if days < md {
296 break;
297 }
298 days -= md;
299 month += 1;
300 }
301 (year, month, days + 1)
302}
303
304fn is_leap(year: u64) -> bool {
305 (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
306}
307
308impl Capability {
309 pub fn new(name: impl Into<String>, source: impl Into<String>) -> Self {
310 Self {
311 name: name.into(),
312 source: source.into(),
313 endpoints: Vec::new(),
314 operations: Vec::new(),
315 entities: Vec::new(),
316 scheduled_tasks: Vec::new(),
317 }
318 }
319
320 pub fn is_empty(&self) -> bool {
321 self.endpoints.is_empty()
322 && self.operations.is_empty()
323 && self.entities.is_empty()
324 && self.scheduled_tasks.is_empty()
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn test_project_spec_new() {
334 let spec = ProjectSpec::new("my-project");
335 assert_eq!(spec.name, "my-project");
336 assert!(spec.analyzed.is_some());
337 assert_eq!(spec.files_analyzed, 0);
338 assert!(spec.capabilities.is_empty());
339 }
340
341 #[test]
342 fn test_timestamp_is_dynamic() {
343 let spec = ProjectSpec::new("test");
344 let ts = spec.analyzed.unwrap();
345 assert!(ts.contains('T'));
347 assert!(ts.ends_with('Z'));
348 assert_ne!(ts, "2026-03-25T00:00:00Z");
349 assert!(ts.starts_with("202"));
351 }
352
353 #[test]
354 fn test_capability_is_empty() {
355 let cap = Capability::new("test", "test.java");
356 assert!(cap.is_empty());
357
358 let mut cap_with_endpoint = Capability::new("test", "test.java");
359 cap_with_endpoint.endpoints.push(Endpoint {
360 method: HttpMethod::Get,
361 path: "/test".to_string(),
362 input: None,
363 validation: Vec::new(),
364 behaviors: Vec::new(),
365 security: None,
366 });
367 assert!(!cap_with_endpoint.is_empty());
368 }
369
370 #[test]
371 fn test_serialization_round_trip_yaml() {
372 let mut spec = ProjectSpec::new("round-trip-test");
373 spec.files_analyzed = 5;
374
375 let mut cap = Capability::new("user", "User.java");
376 cap.endpoints.push(Endpoint {
377 method: HttpMethod::Get,
378 path: "/api/users".to_string(),
379 input: None,
380 validation: Vec::new(),
381 behaviors: vec![Behavior {
382 name: "success".to_string(),
383 condition: None,
384 returns: ResponseSpec {
385 status: 200,
386 body: Some(TypeRef {
387 name: "User".to_string(),
388 fields: BTreeMap::new(),
389 }),
390 },
391 side_effects: Vec::new(),
392 }],
393 security: None,
394 });
395 spec.capabilities.push(cap);
396
397 let yaml = crate::output::to_yaml(&spec).unwrap();
398 assert!(yaml.contains("round-trip-test"));
399 assert!(yaml.contains("/api/users"));
400 assert!(yaml.contains("GET")); let parsed: ProjectSpec = serde_yaml::from_str(&yaml).unwrap();
404 assert_eq!(parsed.name, "round-trip-test");
405 assert_eq!(parsed.capabilities.len(), 1);
406 assert_eq!(parsed.capabilities[0].endpoints[0].path, "/api/users");
407 }
408
409 #[test]
410 fn test_serialization_round_trip_json() {
411 let spec = ProjectSpec::new("json-test");
412 let json = crate::output::to_json(&spec).unwrap();
413 assert!(json.contains("json-test"));
414
415 let parsed: ProjectSpec = serde_json::from_str(&json).unwrap();
416 assert_eq!(parsed.name, "json-test");
417 }
418
419 #[test]
420 fn test_empty_fields_skipped_in_yaml() {
421 let spec = ProjectSpec::new("skip-test");
422 let yaml = crate::output::to_yaml(&spec).unwrap();
423 assert!(!yaml.contains("endpoints"));
426 assert!(!yaml.contains("operations"));
427 assert!(!yaml.contains("entities"));
428 }
429}