Skip to main content

pecto_core/
model.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3
4/// A complete behavior spec for a project.
5#[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/// A capability groups related behaviors (e.g., "user-authentication", "order-management").
21#[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/// An HTTP endpoint extracted from a controller.
36#[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/// A reference to a type with its fields.
80#[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/// A validation rule on an input field.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ValidationRule {
90    pub field: String,
91    pub constraints: Vec<String>,
92}
93
94/// A behavior describes what happens under specific conditions.
95#[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/// A service-layer operation (non-HTTP).
138#[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/// A database entity extracted from JPA/Hibernate/EF annotations.
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct Entity {
153    pub name: String,
154    pub table: String,
155    pub fields: Vec<EntityField>,
156    /// Parent class names (for same-file field inheritance resolution).
157    #[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/// A scheduled/cron task.
171#[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/// A dependency edge between two capabilities.
180#[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/// The kind of dependency between capabilities.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "lowercase")]
192pub enum DependencyKind {
193    Calls,
194    Queries,
195    Listens,
196    Validates,
197}
198
199/// A domain groups related capabilities by business concern.
200#[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/// A traced request flow for an endpoint — the complete call chain.
209#[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/// A single step in a request flow.
217#[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/// The type of step in a request flow.
230#[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    // Convert to ISO 8601 UTC without external dependency
265    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    // Days since 1970-01-01
271    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        // Should be a valid ISO 8601 timestamp, not the old hardcoded one
346        assert!(ts.contains('T'));
347        assert!(ts.ends_with('Z'));
348        assert_ne!(ts, "2026-03-25T00:00:00Z");
349        // Should contain the current year (2026)
350        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")); // HttpMethod serialized as uppercase
401
402        // Deserialize back
403        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        // Empty capabilities list should still show up (it's a top-level field)
424        // but empty sub-fields (endpoints, operations, etc.) should be skipped
425        assert!(!yaml.contains("endpoints"));
426        assert!(!yaml.contains("operations"));
427        assert!(!yaml.contains("entities"));
428    }
429}