capability_example/
manual_grower_flow_cli.rs

1// ---------------- [ File: capability-example/src/manual_grower_flow_cli.rs ]
2crate::ix!();
3
4#[derive(Debug, StructOpt, Builder, Getters, Setters, Clone)]
5#[builder(setter(into))]
6#[getset(get = "pub", set = "pub")]
7#[structopt(
8    name = "ManualGrowerFlow",
9    about = "Runs the step-by-step, human-in-the-loop growth flow"
10)]
11pub struct ManualGrowerFlowCliArgs {
12
13    /// The directory where we store/load partial model state
14    #[structopt(long, help = "Path to partial model JSON directory")]
15    growing_patch_bay: PathBuf,
16
17    /// Optional TOML file with all GrowerInputs
18    #[structopt(
19        long = "toml-inputs-file",
20        help = "If provided, override CLI-based GrowerInputs"
21    )]
22    toml_inputs_file: PathBuf,
23
24    #[structopt(long = "force-override-drift")]
25    force_override_drift: bool,
26
27    /// Current query string
28    #[structopt(long, default_value = "query.txt")]
29    query_file: PathBuf,
30
31    /// Whether to clear the clipboard at startup
32    #[structopt(long = "clear-clipboard")]
33    clear_clipboard: bool,
34
35    /// An optional directory for storing fuzzy-parse failures from the clipboard
36    #[structopt(long = "failed-parse-dir")]
37    failed_parse_dir: Option<PathBuf>,
38}
39
40/// Now we modify our main logic to:
41///  (A) detect which strategy to use (manual vs. automated)
42///  (B) for each missing partial field, call the appropriate fill method from the strategy
43///  (C) if the fill method returns `Some(...)`, we store that in the partial model
44///  (D) if it returns `None`, we exit early (in the manual scenario)
45///
46impl ManualGrowerFlowCliArgs {
47
48    /// The top-level public method.  
49    /// Splits the logic into separate subroutines for clarity.
50    #[instrument(level = "trace", skip_all)]
51    pub async fn run_with_strategy<S: GrowerFlowStrategy>(
52        &self,
53        strategy: &S,
54    ) -> Result<(), ManualGrowerFlowError> {
55        trace!("Starting run_with_strategy() for CLI args: {:?}", self);
56
57        // Step A) Possibly clear the clipboard
58        self.maybe_clear_clipboard();
59
60        // Step B) Try reading the clipboard raw text
61        let raw_clipboard: String = match self.read_raw_clipboard_text() {
62            Ok(txt) => txt,
63            Err(e) => {
64                warn!("Could not read raw clipboard => ignoring snippet. Error: {:?}", e);
65                String::new()
66            }
67        };
68
69        // Step C) Attempt parsing the snippet (in top-down order)
70        let snippet: Option<ClipboardSnippet> =
71            self.parse_clipboard_snippet_in_topdown_order(&raw_clipboard);
72
73        // Step D) If we got a snippet, handle it; otherwise fallback to TOML
74        if let Some(sn) = snippet {
75            self.handle_parsed_snippet(sn, strategy).await
76        } else {
77            self.fallback_toml_flow(strategy).await
78        }
79    }
80
81    // ----------------------------------------------------------------------------------
82    // Subroutine #1: Possibly clear the clipboard
83    // ----------------------------------------------------------------------------------
84    fn maybe_clear_clipboard(&self) {
85        if *self.clear_clipboard() {
86            trace!("User requested --clear-clipboard => clearing the clipboard now.");
87            if let Err(e) = system_clear_clipboard() {
88                warn!("Could not clear system clipboard: {:?}", e);
89            }
90        }
91    }
92
93    // ----------------------------------------------------------------------------------
94    // Subroutine #2: Read raw text from the clipboard
95    // ----------------------------------------------------------------------------------
96    pub fn read_raw_clipboard_text(&self) -> Result<String, FuzzyClipboardParseError> {
97        // A "raw" version that does no JSON parse at all, just string
98        let mut ctx = ClipboardContext::new()
99            .map_err(|_| FuzzyClipboardParseError::ClipboardContextCreationFailed)?;
100        ctx.get_contents()
101            .map_err(|_| FuzzyClipboardParseError::ClipboardGetContentsFailed)
102    }
103
104    // ----------------------------------------------------------------------------------
105    // Subroutine #3: Attempt parse in top-down order, ignoring `StrippedStringSkeleton` fuzzy parse
106    // ----------------------------------------------------------------------------------
107    pub fn parse_clipboard_snippet_in_topdown_order(
108        &self,
109        raw_clipboard: &str,
110    ) -> Option<ClipboardSnippet> {
111        // a) JustifiedGrowerTreeConfiguration
112        if let Ok(Some(parsed)) = attempt_snippet_parse::<JustifiedGrowerTreeConfiguration>(
113            raw_clipboard,
114            "JustifiedGrowerTreeConfiguration",
115            self.failed_parse_dir().as_ref(),
116        ) {
117            return Some(ClipboardSnippet::JustifiedGrowerTreeConfiguration(parsed));
118        }
119
120        // b) JustifiedStringSkeleton
121        if let Ok(Some(parsed)) = attempt_snippet_parse::<JustifiedStringSkeleton>(
122            raw_clipboard,
123            "JustifiedStringSkeleton",
124            self.failed_parse_dir().as_ref(),
125        ) {
126            return Some(ClipboardSnippet::JustifiedStringSkeleton(parsed));
127        }
128
129        // c) We skip `StrippedStringSkeleton` in fuzzy mode because it lacks FuzzyFromJsonValue.
130
131        // d) CoreStringSkeleton
132        if let Ok(Some(parsed)) = attempt_snippet_parse::<CoreStringSkeleton>(
133            raw_clipboard,
134            "CoreStringSkeleton",
135            self.failed_parse_dir().as_ref(),
136        ) {
137            return Some(ClipboardSnippet::CoreStringSkeleton(parsed));
138        }
139
140        // e) AnnotatedLeafHolderExpansions
141        if let Ok(Some(parsed)) = attempt_snippet_parse::<AnnotatedLeafHolderExpansions>(
142            raw_clipboard,
143            "AnnotatedLeafHolderExpansions",
144            self.failed_parse_dir().as_ref(),
145        ) {
146            return Some(ClipboardSnippet::AnnotatedLeafHolderExpansions(parsed));
147        }
148
149        None
150    }
151
152    // ----------------------------------------------------------------------------------
153    // Subroutine #4: We got a snippet => see if we can apply it; else ignore
154    // ----------------------------------------------------------------------------------
155    pub async fn handle_parsed_snippet<S: GrowerFlowStrategy>(
156        &self,
157        snippet: ClipboardSnippet,
158        strategy: &S,
159    ) -> Result<(), ManualGrowerFlowError> {
160        match snippet.target_name() {
161            Some(tn) if !tn.trim().is_empty() => {
162                let file_path = self.growing_patch_bay().join(format!("{}.json", tn));
163                if !file_path.exists() {
164                    error!("Partial file for target='{tn}' does not exist => ignoring snippet => done.");
165                    return Ok(());
166                }
167
168                let mut snippet_partial = match PartiallyGrownModel::load_from_file_fuzzy(&file_path).await {
169                    Ok(pm) => pm,
170                    Err(e) => {
171                        error!("Could not parse partial file for target='{tn}': {e:?} => skipping snippet => done.");
172                        return Ok(());
173                    }
174                };
175
176                let missing_step = snippet_partial.validate().err();
177                let partial_rank = missing_field_rank(&missing_step);
178                let snippet_rank = snippet_field_rank(&snippet);
179                trace!("Target='{}': partial missing={:?} => rank={} ; snippet rank={}", tn, missing_step, partial_rank, snippet_rank);
180
181                if partial_rank == 0 {
182                    warn!("Partial is already fully valid => ignoring snippet => done.");
183                    return Ok(());
184                } 
185                else if snippet_rank < partial_rank {
186                    // Instead of exiting, we proceed with the partial model as-is
187                    warn!("Snippet is for an earlier step => ignoring snippet, continuing from partial as-is...");
188
189                    // We reuse the same loop, but we haven't changed snippet_partial, so we just pass it
190                    // to post_snippet_partial_loop so it will attempt to fill whatever is missing 
191                    // (the snippet is ignored).
192                    self.post_snippet_partial_loop(snippet_partial, file_path, strategy).await
193                }
194                else if snippet_rank == partial_rank {
195                    // Perfect match => apply snippet
196                    snippet_partial.apply_clipboard_snippet(snippet);
197                    snippet_partial.save_to_file(&file_path).await?;
198                    self.post_snippet_partial_loop(snippet_partial, file_path, strategy).await
199                }
200                else {
201                    // snippet_rank > partial_rank => snippet is ahead
202                    if snippet_rank == partial_rank + 1 {
203                        info!("Snippet is exactly one step ahead => applying anyway...");
204                        snippet_partial.apply_clipboard_snippet(snippet);
205                        snippet_partial.save_to_file(&file_path).await?;
206                        self.post_snippet_partial_loop(snippet_partial, file_path, strategy).await
207                    } else {
208                        error!("Snippet is more than one step ahead => ignoring snippet => done.");
209                        Ok(())
210                    }
211                }
212            }
213            _ => {
214                warn!("Clipboard snippet has no valid target_name => ignoring => done.");
215                Ok(())
216            }
217        }
218    }
219
220    /// A small subroutine to handle the repeated logic after we've applied a snippet 
221    /// but the partial might still not be fully valid.
222    async fn post_snippet_partial_loop<S: GrowerFlowStrategy>(
223        &self,
224        mut partial: PartiallyGrownModel,
225        file_path: PathBuf,
226        strategy: &S,
227    ) -> Result<(), ManualGrowerFlowError> {
228        loop {
229            match partial.validate() {
230                Ok(_) => {
231                    info!("Partial is now fully valid => finalize => done.");
232                    let fm = GrowerModel::finalize_from_valid_partial(partial.clone())
233                        .map_err(ManualGrowerFlowError::GrowerModelGenError)?;
234                    fm.save_to_file(&file_path).await?;
235                    return Ok(());
236                }
237                Err(missing) => {
238                    let gi = partial.grower_inputs().clone().unwrap();
239                    if let Err(e2) = manual_flow_try_to_fix_missing_field(
240                        strategy,
241                        &mut partial,
242                        &gi,
243                        &missing,
244                        self,
245                    )
246                    .await
247                    {
248                        warn!("Could not fix missing field => exiting: {:?}", e2);
249                        return Ok(());
250                    }
251                    partial.save_to_file(&file_path).await?;
252                }
253            }
254        }
255    }
256
257    // ----------------------------------------------------------------------------------
258    // Subroutine #5: No snippet => fallback to TOML approach
259    // ----------------------------------------------------------------------------------
260    pub async fn fallback_toml_flow<S: GrowerFlowStrategy>(
261        &self,
262        strategy: &S,
263    ) -> Result<(), ManualGrowerFlowError> {
264        trace!("No parseable snippet => continuing with TOML flow.");
265
266        let grower_inputs = self.load_and_validate_toml()?;
267        let mut partial_model = self.open_or_create_partial(&grower_inputs).await?;
268        partial_model = self.resolve_input_drift(&grower_inputs, partial_model).await?;
269
270        loop {
271            match partial_model.validate() {
272                Ok(()) => {
273                    info!("Partial is fully valid => finalize => done.");
274                    let final_model = GrowerModel::finalize_from_valid_partial(partial_model.clone())
275                        .map_err(ManualGrowerFlowError::GrowerModelGenError)?;
276                    let partial_path = self.calculate_partial_path(&grower_inputs);
277                    final_model.save_to_file(&partial_path).await?;
278                    return Ok(());
279                }
280                Err(missing) => {
281                    if let Err(e) = manual_flow_try_to_fix_missing_field(
282                        strategy,
283                        &mut partial_model,
284                        &grower_inputs,
285                        &missing,
286                        self,
287                    )
288                    .await
289                    {
290                        warn!("Could not fill missing field => exiting. Error={:?}", e);
291                        return Ok(());
292                    }
293                }
294            }
295        }
296    }
297
298
299    /// Figures out the correct partial path for a given grower_inputs
300    pub fn calculate_partial_path(&self, grower_inputs: &GrowerInputs) -> PathBuf {
301        self.growing_patch_bay
302            .join(format!("{}.json", grower_inputs.target()))
303    }
304
305    #[instrument(level = "trace", skip_all)]
306    fn load_and_validate_toml(&self) -> Result<GrowerInputs, ManualGrowerFlowError> {
307        debug!("Loading GrowerInputs from: {:?}", self.toml_inputs_file());
308        let gi = GrowerInputs::from_toml_file(self.toml_inputs_file())?;
309        gi.validate()?;
310        Ok(gi)
311    }
312
313    #[instrument(level = "trace", skip_all)]
314    async fn open_or_create_partial(
315        &self,
316        grower_inputs: &GrowerInputs,
317    ) -> Result<PartiallyGrownModel, ManualGrowerFlowError> {
318        let partial_path = self.calculate_partial_path(grower_inputs);
319        maybe_write_initial_partial_model_file(&partial_path).await?;
320        let partial_model = PartiallyGrownModel::load_from_file_fuzzy(&partial_path).await?;
321        Ok(partial_model)
322    }
323
324    #[instrument(level = "trace", skip_all)]
325    async fn resolve_input_drift(
326        &self,
327        grower_inputs: &GrowerInputs,
328        mut partial_model: PartiallyGrownModel,
329    ) -> Result<PartiallyGrownModel, ManualGrowerFlowError> {
330        let partial_path = self.calculate_partial_path(grower_inputs);
331
332        if let Some(pm_grower_inputs) = partial_model.grower_inputs() {
333            if pm_grower_inputs != grower_inputs {
334                if !self.force_override_drift() {
335                    if partial_model.essentially_empty() {
336                        warn!("Inputs drift but partial is basically empty => overwriting grower_inputs");
337                        partial_model = PartiallyGrownModel::from(grower_inputs.clone());
338                        partial_model.save_to_file(&partial_path).await?;
339                    } else {
340                        warn!("Inputs drift => exiting early unless forced override is used.");
341                        return Ok(partial_model);
342                    }
343                } else {
344                    warn!("Inputs drift => forced override => clobbering partial");
345                    partial_model = PartiallyGrownModel::from(grower_inputs.clone());
346                    partial_model.save_to_file(&partial_path).await?;
347                }
348            }
349        } else {
350            partial_model.set_grower_inputs(Some(grower_inputs.clone()));
351            partial_model.save_to_file(&partial_path).await?;
352        }
353
354        Ok(partial_model)
355    }
356}