Skip to main content

conjure_oxide/
cli.rs

1use std::path::PathBuf;
2
3use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
4
5use clap_complete::Shell;
6use conjure_cp::settings::{
7    Channelling, DEFAULT_HEURISTIC_SEED, DEFAULT_MINION_DISCRETE_THRESHOLD, Heuristic,
8    Parser as InputParser, QuantifiedExpander, Rewriter, SolverFamily,
9};
10use conjure_cp::solver::adaptors::{MinionValueOrder, MinionVariableOrder};
11use git_version::git_version;
12
13use crate::{pretty, solve, test_solve};
14
15pub(crate) const LOGGING_HELP_HEADING: Option<&str> = Some("Logging & Output");
16pub(crate) const CONFIGURATION_HELP_HEADING: Option<&str> = Some("Configuration");
17
18/// All subcommands of conjure-oxide
19#[derive(Clone, Debug, Subcommand)]
20pub enum Command {
21    /// Solve a model
22    Solve(solve::Args),
23    /// Print the JSON info file schema
24    PrintJsonSchema,
25    /// Tests whether the Essence model is solvable with Conjure Oxide, and whether it gets the
26    /// same solutions as Conjure.
27    ///
28    /// Return-code will be 0 if the solutions match, 1 if they don't, and >1 on crash.
29    TestSolve(test_solve::Args),
30    /// Generate a completion script for the shell provided
31    Completion(CompletionArgs),
32    Pretty(pretty::Args),
33    // Run the language server
34    ServerLSP,
35}
36
37/// Global command line arguments.
38#[derive(Clone, Debug, Parser)]
39#[command(
40    author,
41    about = "Conjure Oxide: Automated Constraints Modelling Toolkit",
42    before_help = "Full documentation can be found online at: https://conjure-cp.github.io/conjure-oxide",
43    // Free `-h` for `--heuristic`; help remains available as `--help`.
44    disable_help_flag = true,
45    version = git_version!(),
46    disable_version_flag = true,
47    display_name = "conjure-oxide",
48    // clap's derive turns this on for a required subcommand; keep the concise
49    // "requires a subcommand" error instead of dumping the full help.
50    arg_required_else_help = false
51)]
52pub struct Cli {
53    #[command(subcommand)]
54    pub subcommand: Command,
55
56    #[command(flatten)]
57    pub global_args: GlobalArgs,
58
59    /// Print version
60    // `ArgAction::Version` is handled by clap while parsing, so `--version` works on its own,
61    // without the otherwise-required subcommand.
62    #[arg(long = "version", short = 'V', action = ArgAction::Version)]
63    pub version: (),
64}
65
66#[derive(Debug, Clone, Args)]
67pub struct GlobalArgs {
68    /// Print help
69    #[arg(long, action = clap::ArgAction::Help, global = true)]
70    pub help: (),
71
72    /// Extra rule sets to enable
73    #[arg(long, value_name = "EXTRA_RULE_SETS", global = true)]
74    pub extra_rule_sets: Vec<String>,
75
76    /// Increase stderr logging detail (-v: stages, -vv: rule applications, -vvv: rule attempts).
77    ///
78    /// Rule-attempt logging can be expensive and produce a very large amount of output.
79    #[arg(
80        long,
81        short = 'v',
82        action = ArgAction::Count,
83        global = true,
84        conflicts_with = "quiet",
85        help_heading = LOGGING_HELP_HEADING
86    )]
87    pub verbose: u8,
88
89    /// Disable warning and progress logs on stderr
90    #[arg(long, short = 'q', global = true, help_heading = LOGGING_HELP_HEADING)]
91    pub quiet: bool,
92
93    /// Output file for the default rule trace.
94    #[arg(long, global = true, help_heading=LOGGING_HELP_HEADING)]
95    pub rule_trace: Option<PathBuf>,
96
97    /// Output file for aggregated rule-application counts.
98    ///
99    /// The file is updated incrementally in the format:
100    /// `total_rule_applications: N`, followed by one line per rule.
101    #[arg(long, global = true, help_heading=LOGGING_HELP_HEADING)]
102    pub rule_trace_aggregates: Option<PathBuf>,
103
104    /// Continue rule trace generation during solver-time CDP rewrites.
105    ///
106    /// This is off by default, so follow-up dominance-blocking rewrites do not contribute to the
107    /// trace.
108    #[arg(long, default_value_t = false, global = true, help_heading=LOGGING_HELP_HEADING)]
109    pub rule_trace_cdp: bool,
110
111    /// Output file for the rule-attempt trace in CSV format.
112    ///
113    /// Each row includes: elapsed_s, rule_level, rule_name, rule_set, status, expression.
114    #[arg(
115        long = "rule-attempt-trace",
116        global = true,
117        help_heading=LOGGING_HELP_HEADING
118    )]
119    pub rule_attempt_trace: Option<PathBuf>,
120
121    /// Which parser to use.
122    ///
123    /// Possible values: `tree-sitter`, `via-conjure`.
124    #[arg(
125        long,
126        default_value_t = InputParser::default(),
127        value_parser = parse_parser,
128        global = true,
129        help_heading = CONFIGURATION_HELP_HEADING
130    )]
131    pub parser: InputParser,
132
133    /// Which rewriter to use.
134    ///
135    /// Possible values: `baseline`, `optimised`, `baseline+prefilter`, or `baseline+worklist`.
136    ///
137    /// Option meanings:
138    /// - `prefilter`: skip rules whose declared expression kinds cannot match; strong win vs
139    ///   baseline and part of `optimised`.
140    /// - `worklist`: drive rewriting from persistent dirty queues instead of repeated full scans;
141    ///   strong win vs baseline and part of `optimised`.
142    #[arg(long, default_value_t = Rewriter::default(), value_parser = parse_rewriter, global = true, help_heading = CONFIGURATION_HELP_HEADING)]
143    pub rewriter: Rewriter,
144
145    /// Which strategy to use for expanding quantified variables in comprehensions.
146    ///
147    /// Possible values: `auto`, `native`, `via-solver`, `via-solver-ac`. `auto` chooses
148    /// between native and solver-backed expansion from the comprehension's estimated size and
149    /// available pruning constraints.
150    #[arg(
151        long,
152        default_value_t = QuantifiedExpander::Auto,
153        value_parser = parse_comprehension_expander,
154        global = true,
155        help_heading = CONFIGURATION_HELP_HEADING
156    )]
157    pub comprehension_expander: QuantifiedExpander,
158
159    /// Heuristic for selecting an answer when multiple modelling choices are applicable.
160    ///
161    /// Possible values: `f` (first), `r` (random), `c` (compact), `i` (interactive). Compact
162    /// minimises the representation-domain size for representation choices and the resulting AST
163    /// depth for equally-applicable rewrite rules. Interactive prompts on stderr, or uses
164    /// `--responses` when provided. `x` (all) is reserved for model generation and is not
165    /// supported by the CLI yet.
166    #[arg(
167        long,
168        short = 'h',
169        default_value_t = Heuristic::Compact,
170        value_parser = parse_cli_heuristic,
171        global = true,
172        help_heading = CONFIGURATION_HELP_HEADING
173    )]
174    pub heuristic: Heuristic,
175
176    /// Comma-separated 1-based answers for the interactive heuristic (`-h i`).
177    ///
178    /// If provided, these are used as the answers during interactive model generation instead of
179    /// prompting the user.
180    #[arg(
181        long,
182        value_name = "INTS",
183        value_delimiter = ',',
184        global = true,
185        help_heading = CONFIGURATION_HELP_HEADING
186    )]
187    pub responses: Vec<usize>,
188
189    /// Seed used by the random heuristic.
190    #[arg(
191        long,
192        default_value_t = DEFAULT_HEURISTIC_SEED,
193        global = true,
194        help_heading = CONFIGURATION_HELP_HEADING
195    )]
196    pub seed: u64,
197
198    /// Seed used by the backend solver's random search behaviour.
199    #[arg(
200        long,
201        default_value_t = 0,
202        global = true,
203        help_heading = CONFIGURATION_HELP_HEADING
204    )]
205    pub solver_seed: u32,
206
207    /// Whether multiple representations of the same declaration may be channelled together.
208    ///
209    /// Possible values: `no`, `yes`. Channelling is disabled by default. Enable `yes` to allow
210    /// different representations of the same variable at different call sites, e.g.
211    /// `1 in (x :: set (representation packed) of int) /\ 2 in (x :: set (representation occurrence) of int)`.
212    #[arg(
213        long,
214        default_value_t = Channelling::No,
215        value_parser = parse_cli_channelling,
216        global = true,
217        help_heading = CONFIGURATION_HELP_HEADING
218    )]
219    pub channelling: Channelling,
220
221    /// Solver to use.
222    ///
223    /// Possible values: `minion`, `sat`, `z3`.
224    ///
225    /// How a model is expressed for the chosen solver -- which SAT encoding an integer gets, or
226    /// which Z3 theory -- is a modelling choice made per declaration, not part of the solver name.
227    /// Use `--heuristic` to steer those choices and `--channelling` to allow more than one per
228    /// declaration.
229    #[arg(
230        long,
231        value_name = "SOLVER",
232        value_parser = parse_solver_family,
233        default_value = "minion",
234        short = 's',
235        global = true,
236        help_heading = CONFIGURATION_HELP_HEADING
237    )]
238    pub solver: SolverFamily,
239
240    /// Int-domain size threshold for using Minion `DISCRETE` variables.
241    ///
242    /// If an int domain has size <= this value, Conjure Oxide emits `DISCRETE`; otherwise `BOUND`.
243    #[arg(
244        long,
245        default_value_t = DEFAULT_MINION_DISCRETE_THRESHOLD,
246        global = true,
247        help_heading = CONFIGURATION_HELP_HEADING
248    )]
249    pub minion_discrete_threshold: usize,
250
251    /// Override Minion variable ordering.
252    ///
253    /// Possible values: `static`, `sdf`, `srf`, `ldf`, `random`, `conflict`, `wdeg`,
254    /// `domoverwdeg`.
255    #[arg(
256        long,
257        value_name = "ORDER",
258        value_parser = parse_minion_variable_order,
259        global = true,
260        help_heading = CONFIGURATION_HELP_HEADING
261    )]
262    pub minion_varorder: Option<MinionVariableOrder>,
263
264    /// Override Minion value ordering.
265    ///
266    /// Possible values: `ascend`, `descend`, `random`.
267    #[arg(
268        long,
269        value_name = "ORDER",
270        value_parser = parse_minion_value_order,
271        global = true,
272        help_heading = CONFIGURATION_HELP_HEADING
273    )]
274    pub minion_valorder: Option<MinionValueOrder>,
275
276    /// Save a solver input file to <filename>.
277    ///
278    /// This input file will be in a format compatible by the command-line
279    /// interface of the selected solver. For example, when the solver is Minion,
280    /// a valid .minion file will be output.
281    ///
282    /// This file is for informational purposes only; the results of running
283    /// this file cannot be used by Conjure Oxide in any way.
284    #[arg(long,global=true, value_names=["filename"], next_line_help=true, help_heading=LOGGING_HELP_HEADING)]
285    pub save_solver_input_file: Option<PathBuf>,
286
287    /// Stop the solver after the given cumulative wall-clock timeout.
288    ///
289    /// Minion has one-second timeout resolution, so finer durations are rounded up.
290    #[arg(long, global = true, help_heading = CONFIGURATION_HELP_HEADING)]
291    pub solver_timeout: Option<humantime::Duration>,
292
293    /// Write general logs to this file
294    #[arg(long, value_name = "PATH", global = true, help_heading = LOGGING_HELP_HEADING)]
295    pub log_file: Option<PathBuf>,
296
297    /// Format used by --log-file [default: text]
298    #[arg(long, value_enum, requires = "log_file", global = true, help_heading = LOGGING_HELP_HEADING)]
299    pub log_format: Option<LogFormat>,
300
301    /// Detail written by --log-file [default: stages]
302    #[arg(long, value_enum, requires = "log_file", global = true, help_heading = LOGGING_HELP_HEADING)]
303    pub log_detail: Option<LogDetail>,
304}
305
306#[derive(Clone, Copy, Debug, Default, ValueEnum)]
307pub enum LogFormat {
308    #[default]
309    Text,
310    Json,
311}
312
313#[derive(Clone, Copy, Debug, Default, ValueEnum)]
314pub enum LogDetail {
315    #[default]
316    Stages,
317    Applications,
318    Attempts,
319}
320
321#[derive(Debug, Clone, Args)]
322pub struct CompletionArgs {
323    /// Shell type for which to generate the completion script
324    #[arg(value_enum)]
325    pub shell: Shell,
326}
327
328#[derive(Debug, Clone, Copy, clap::ValueEnum)]
329pub enum ShellTypes {
330    Bash,
331    Zsh,
332    Fish,
333    PowerShell,
334    Elvish,
335}
336
337fn parse_comprehension_expander(input: &str) -> Result<QuantifiedExpander, String> {
338    input.parse()
339}
340
341fn parse_cli_heuristic(input: &str) -> Result<Heuristic, String> {
342    match input.parse::<Heuristic>()? {
343        Heuristic::All => {
344            Err("heuristic 'x' (all) is not supported by the command line yet".to_string())
345        }
346        heuristic => Ok(heuristic),
347    }
348}
349
350fn parse_cli_channelling(input: &str) -> Result<Channelling, String> {
351    input.parse::<Channelling>()
352}
353
354fn parse_rewriter(input: &str) -> Result<Rewriter, String> {
355    input.parse::<Rewriter>()
356}
357
358fn parse_solver_family(input: &str) -> Result<SolverFamily, String> {
359    input.parse()
360}
361
362fn parse_parser(input: &str) -> Result<InputParser, String> {
363    input.parse()
364}
365
366fn parse_minion_value_order(input: &str) -> Result<MinionValueOrder, String> {
367    match input {
368        "ascend" => Ok(MinionValueOrder::Ascend),
369        "descend" => Ok(MinionValueOrder::Descend),
370        "random" => Ok(MinionValueOrder::Random),
371        other => Err(format!(
372            "unknown minion value order '{other}', expected one of: ascend, descend, random"
373        )),
374    }
375}
376
377fn parse_minion_variable_order(input: &str) -> Result<MinionVariableOrder, String> {
378    match input {
379        "static" => Ok(MinionVariableOrder::Static),
380        "sdf" => Ok(MinionVariableOrder::SmallestDomainFirst),
381        "srf" => Ok(MinionVariableOrder::SmallestRatioFirst),
382        "ldf" => Ok(MinionVariableOrder::LargestDomainFirst),
383        "random" => Ok(MinionVariableOrder::Random),
384        "conflict" => Ok(MinionVariableOrder::Conflict),
385        "wdeg" => Ok(MinionVariableOrder::WeightedDegree),
386        "domoverwdeg" => Ok(MinionVariableOrder::DomainOverWeightedDegree),
387        other => Err(format!(
388            "unknown minion variable order '{other}', expected one of: static, sdf, srf, ldf, \
389             random, conflict, wdeg, domoverwdeg"
390        )),
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    /// Regression test for #1631: `--version` used to fail as it requires a subcommand.
399    #[test]
400    fn version_flag_works_without_a_subcommand() {
401        for flag in ["--version", "-V"] {
402            let err = Cli::try_parse_from(["conjure-oxide", flag]).unwrap_err();
403            assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
404            assert!(err.to_string().starts_with("conjure-oxide "));
405        }
406    }
407
408    #[test]
409    fn compact_is_the_default_cli_heuristic() {
410        let cli = Cli::try_parse_from(["conjure-oxide", "solve", "model.essence"]).unwrap();
411        assert_eq!(cli.global_args.heuristic, Heuristic::Compact);
412    }
413
414    #[test]
415    fn auto_is_the_default_comprehension_expander() {
416        let cli = Cli::try_parse_from(["conjure-oxide", "solve", "model.essence"]).unwrap();
417        assert_eq!(
418            cli.global_args.comprehension_expander,
419            QuantifiedExpander::Auto
420        );
421    }
422
423    #[test]
424    fn solver_seed_defaults_to_zero_and_can_be_overridden() {
425        let cli = Cli::try_parse_from(["conjure-oxide", "solve", "model.essence"]).unwrap();
426        assert_eq!(cli.global_args.solver_seed, 0);
427
428        let cli = Cli::try_parse_from([
429            "conjure-oxide",
430            "solve",
431            "model.essence",
432            "--solver-seed",
433            "42",
434        ])
435        .unwrap();
436        assert_eq!(cli.global_args.solver_seed, 42);
437    }
438
439    #[test]
440    fn parses_all_minion_variable_orders() {
441        let cases = [
442            ("static", MinionVariableOrder::Static),
443            ("sdf", MinionVariableOrder::SmallestDomainFirst),
444            ("srf", MinionVariableOrder::SmallestRatioFirst),
445            ("ldf", MinionVariableOrder::LargestDomainFirst),
446            ("random", MinionVariableOrder::Random),
447            ("conflict", MinionVariableOrder::Conflict),
448            ("wdeg", MinionVariableOrder::WeightedDegree),
449            ("domoverwdeg", MinionVariableOrder::DomainOverWeightedDegree),
450        ];
451
452        for (name, expected) in cases {
453            let cli = Cli::try_parse_from([
454                "conjure-oxide",
455                "solve",
456                "model.essence",
457                "--minion-varorder",
458                name,
459            ])
460            .unwrap();
461            assert_eq!(cli.global_args.minion_varorder, Some(expected));
462        }
463    }
464}