1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::Path;
4
5use serde_json::Value;
6use sofie_model::App;
7use sofie_model::naming::file_stem;
8use sofie_values::Seed;
9
10use crate::gen_command::GenError;
11use crate::http_client::CookieJars;
12use crate::inputs::{clock_seed, valid_case};
13use crate::local_app::{LocalApp, bench_member};
14use crate::source_edits::{files_with_extension, read};
15use crate::targets::{Target, Targets, member_areas, targets};
16
17const SEEDS: &str = "seeds";
18const SHOWN_REFUSAL: usize = 300;
19
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct SeedReport {
22 pub routes: Vec<SeededRoute>,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct SeededRoute {
27 pub route: String,
28 pub created: usize,
29 pub refused: usize,
30 pub first_refusal: Option<String>,
31}
32
33pub fn seed(root: &Path, volume: usize) -> Result<SeedReport, GenError> {
38 let app = sofie_model::load(root).map_err(GenError::Model)?;
39 let found = targets(&app)?;
40 let files = seed_files(&app, &found)?;
41 let creating: Vec<Target> = found
42 .targets
43 .into_iter()
44 .filter(|target| {
45 target.not_postable().is_none()
46 && (files.contains_key(&target.name) || (volume > 0 && !target.fields.is_empty()))
47 })
48 .collect();
49 let mut report = SeedReport { routes: Vec::new() };
50 if creating.is_empty() {
51 return Ok(report);
52 }
53 let local = LocalApp::start(root, "seed")?;
54 let jars = sign_in(&local, &creating)?;
55 let mut seed = Seed::new(clock_seed());
58 for target in &creating {
59 let mut seeded = SeededRoute {
60 route: target.name.clone(),
61 created: 0,
62 refused: 0,
63 first_refusal: None,
64 };
65 let fixed = files.get(&target.name).map_or(&[][..], Vec::as_slice);
66 for body in fixed {
67 seeded.post(&local, &jars, target, &body.to_string());
68 }
69 let generated = if target.fields.is_empty() { 0 } else { volume };
70 for _ in 0..generated {
71 let case = valid_case(&target.fields, &mut seed);
72 seeded.post(&local, &jars, target, &case.body);
73 }
74 report.routes.push(seeded);
75 }
76 Ok(report)
77}
78
79fn sign_in(local: &LocalApp, creating: &[Target]) -> Result<CookieJars, GenError> {
80 let mut jars = CookieJars::default();
81 for (area, paths) in member_areas(creating) {
82 local.sign_in(&mut jars, area, paths, &bench_member(area))?;
83 }
84 Ok(jars)
85}
86
87impl SeededRoute {
88 fn post(&mut self, local: &LocalApp, jars: &CookieJars, target: &Target, body: &str) {
89 let cookie = jars.cookie_header(&target.area);
90 let refusal = match local.send_json("POST", &target.path, cookie, body) {
91 Ok(response) if response.status < 400 => {
92 self.created += 1;
93 return;
94 }
95 Ok(response) => format!(
96 "{}: {}",
97 response.status,
98 String::from_utf8_lossy(&response.body)
99 ),
100 Err(error) => error.to_string(),
101 };
102 self.refused += 1;
103 if self.first_refusal.is_none() {
104 let end = refusal.floor_char_boundary(SHOWN_REFUSAL);
105 self.first_refusal = Some(format!("{} for {body}", &refusal[..end]));
106 }
107 }
108}
109
110fn seed_files(app: &App, found: &Targets) -> Result<BTreeMap<String, Vec<Value>>, GenError> {
113 let mut files = BTreeMap::new();
114 for module in &app.modules {
115 let directory = module.krate.path.join(SEEDS);
116 for file in files_with_extension(&directory, "json")? {
117 let route = format!("{}.{}", module.krate.crate_name, file_stem(&file));
118 if let Some(reason) = unseedable(found, &route) {
119 return Err(GenError::App(format!(
120 "{}: cannot seed `{route}`: {reason}; a seed file is named \
121 `<area>.<handler>.json` after a POST route without path parameters",
122 file.display()
123 )));
124 }
125 let text = read(&file)?;
126 let inputs: Vec<Value> = serde_json::from_str(&text).map_err(|error| {
127 GenError::App(format!(
128 "{}: not a JSON array of inputs: {error}",
129 file.display()
130 ))
131 })?;
132 files.insert(route, inputs);
133 }
134 }
135 Ok(files)
136}
137
138fn unseedable(found: &Targets, route: &str) -> Option<String> {
140 if let Some(target) = found.targets.iter().find(|target| target.name == route) {
141 return target.not_postable().map(str::to_owned);
142 }
143 if let Some(skipped) = found.skipped.iter().find(|skipped| {
144 skipped
145 .strip_prefix(route)
146 .is_some_and(|rest| rest.starts_with(':'))
147 }) {
148 return Some(skipped[route.len() + 1..].trim().to_owned());
149 }
150 Some("no route has that name (`sofie routes` lists them)".to_owned())
151}
152
153impl fmt::Display for SeedReport {
155 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
156 if self.routes.is_empty() {
157 return writeln!(
158 formatter,
159 "sofie seed: nothing to seed; no POST route without a path parameter takes an \
160 input, or there is no volume and no seed file"
161 );
162 }
163 for route in &self.routes {
164 write!(formatter, "{}: {} created", route.route, route.created)?;
165 if route.refused > 0 {
166 write!(formatter, ", {} refused", route.refused)?;
167 }
168 writeln!(formatter)?;
169 if let Some(refusal) = &route.first_refusal {
170 writeln!(formatter, " first refusal: {refusal}")?;
171 }
172 }
173 Ok(())
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use sofie_model::Method;
180
181 use super::*;
182
183 fn target(name: &str, method: Method, path: &str) -> Target {
184 Target {
185 name: name.to_owned(),
186 module: "exams".to_owned(),
187 method,
188 path: path.to_owned(),
189 area: "web".to_owned(),
190 member: None,
191 fields: Vec::new(),
192 }
193 }
194
195 #[test]
196 fn a_seed_file_names_why_its_route_takes_no_inputs() {
197 let found = Targets {
198 targets: vec![
199 target("exams.web.add_question", Method::Post, "/questions"),
200 target("exams.web.questions", Method::Get, "/questions"),
201 target("exams.web.answer", Method::Post, "/questions/{id}/answers"),
202 ],
203 skipped: vec!["exams.admin.add: area `admin` has no literal prefix".to_owned()],
204 };
205
206 assert_eq!(unseedable(&found, "exams.web.add_question"), None);
207 assert_eq!(
208 unseedable(&found, "exams.web.questions").as_deref(),
209 Some("it is not a POST route")
210 );
211 assert_eq!(
212 unseedable(&found, "exams.web.answer").as_deref(),
213 Some("its path takes a parameter")
214 );
215 assert_eq!(
216 unseedable(&found, "exams.admin.add").as_deref(),
217 Some("area `admin` has no literal prefix")
218 );
219 assert!(
220 unseedable(&found, "exams.web.missing")
221 .is_some_and(|reason| reason.contains("no route"))
222 );
223 }
224}