1
use std::fs::File;
2
use std::fs::read_to_string;
3
use std::io::Write;
4
use std::path::Path;
5
use std::path::PathBuf;
6
use std::process::ExitCode;
7

            
8
use clap::Parser;
9
use clap::Subcommand;
10
use duct::cmd;
11
use itertools::Itertools;
12
use log::debug;
13
use log::info;
14
use merc_lts::LtsFormat;
15
use merc_lts::guess_lts_format_from_extension;
16
use merc_lts::read_aut;
17
use merc_lts::write_aut;
18
use merc_symbolic::bits_to_bdd;
19
use merc_vpg::Projected;
20
use merc_vpg::ProjectedLts;
21
use merc_vpg::project_feature_transition_system_iter;
22
use oxidd::BooleanFunction;
23

            
24
use merc_symbolic::CubeIterAll;
25
use merc_symbolic::FormatConfig;
26
use merc_syntax::UntypedStateFrmSpec;
27
use merc_tools::VerbosityFlag;
28
use merc_tools::Version;
29
use merc_tools::VersionFlag;
30
use merc_tools::format_key_values_json;
31
use merc_unsafety::print_allocator_metrics;
32
use merc_utilities::MercError;
33
use merc_utilities::Timing;
34
use merc_vpg::FeatureDiagram;
35
use merc_vpg::ParityGameFormat;
36
use merc_vpg::PgDot;
37
use merc_vpg::Player;
38
use merc_vpg::VpgDot;
39
use merc_vpg::ZielonkaVariant;
40
use merc_vpg::compute_reachable;
41
use merc_vpg::guess_format_from_extension;
42
use merc_vpg::make_vpg_total;
43
use merc_vpg::project_variability_parity_games_iter;
44
use merc_vpg::read_fts;
45
use merc_vpg::read_pg;
46
use merc_vpg::read_vpg;
47
use merc_vpg::solve_variability_product_zielonka;
48
use merc_vpg::solve_variability_zielonka;
49
use merc_vpg::solve_zielonka;
50
use merc_vpg::translate;
51
use merc_vpg::translate_vpg;
52
use merc_vpg::verify_variability_product_zielonka_solution;
53
use merc_vpg::write_pg;
54
use merc_vpg::write_vpg;
55

            
56
/// Default node capacity for the Oxidd decision diagram manager. The choice
57
/// for this value is fairly arbitrary.
58
const DEFAULT_OXIDD_NODE_CAPACITY: usize = 2024;
59

            
60
/// "A command line tool for variability parity games
61
#[derive(clap::Parser, Debug)]
62
#[command(
63
    arg_required_else_help = true
64
)]
65
struct Cli {
66
    #[command(flatten)]
67
    version: VersionFlag,
68

            
69
    #[command(flatten)]
70
    verbosity: VerbosityFlag,
71

            
72
    #[arg(long, global = true)]
73
    timings: bool,
74

            
75
    #[arg(long, global = true, default_value_t = 1)]
76
    oxidd_workers: u32,
77

            
78
    #[arg(long, global = true, default_value_t = DEFAULT_OXIDD_NODE_CAPACITY)]
79
    oxidd_node_capacity: usize,
80

            
81
    #[arg(long, global = true)]
82
    oxidd_cache_capacity: Option<usize>,
83

            
84
    #[command(subcommand)]
85
    commands: Option<Commands>,
86
}
87

            
88
#[derive(Debug, Subcommand)]
89
enum Commands {
90
    Solve(SolveArgs),
91
    Reachable(ReachableArgs),
92
    Project(ProjectArgs),
93
    ProjectVpg(ProjectVpgArgs),
94
    Translate(TranslateArgs),
95
    TranslateVpg(TranslateVpgArgs),
96
    Display(DisplayArgs),
97
}
98

            
99
/// Solve a parity game
100
#[derive(clap::Args, Debug)]
101
struct SolveArgs {
102
    filename: String,
103

            
104
    /// The input parity game file format
105
    #[arg(long)]
106
    format: Option<ParityGameFormat>,
107

            
108
    /// Sets the solving variant used for variability parity games
109
    #[arg(long)]
110
    solve_variant: Option<ZielonkaVariant>,
111

            
112
    /// Output the solution for every single vertex instead of only the initial vertex.
113
    #[arg(long, default_value_t = false)]
114
    full_solution: bool,
115

            
116
    /// Whether to verify the solution after computing it
117
    #[arg(long, default_value_t = false)]
118
    verify_solution: bool,
119
}
120

            
121
/// Compute the reachable part of a parity game
122
#[derive(clap::Args, Debug)]
123
struct ReachableArgs {
124
    /// The filename of the parity game to compute the reachable part of
125
    filename: PathBuf,
126

            
127
    /// The output filename for the reachable part of the parity game
128
    output: PathBuf,
129

            
130
    #[arg(long, short)]
131
    format: Option<ParityGameFormat>,
132
}
133

            
134
/// Project a feature transition system to a set of transition systems
135
#[derive(clap::Args, Debug)]
136
struct ProjectArgs {
137
    /// The filename of the variability parity game to project
138
    filename: PathBuf,
139

            
140
    /// The filename of the feature diagram
141
    feature_diagram_filename: PathBuf,
142

            
143
    /// The output filename pattern for the projected labelled transition systems.
144
    output: String,
145

            
146
    /// The input featured transition system file format
147
    #[arg(long, short)]
148
    format: Option<LtsFormat>,
149
}
150

            
151
/// Project a variability parity game to a set of parity games
152
#[derive(clap::Args, Debug)]
153
struct ProjectVpgArgs {
154
    /// The filename of the variability parity game to project
155
    filename: PathBuf,
156

            
157
    /// The output filename pattern for the projected parity games.
158
    output: String,
159

            
160
    /// Whether to compute the reachable part of each projection.
161
    #[arg(long, short)]
162
    reachable: bool,
163

            
164
    #[arg(long, short)]
165
    format: Option<ParityGameFormat>,
166
}
167

            
168
/// Translate a labelled transition system and a modal formula into a parity game
169
#[derive(clap::Args, Debug)]
170
struct TranslateArgs {
171
    /// The filename of the labelled transition system
172
    labelled_transition_system: PathBuf,
173

            
174
    /// The input labelled transition system file format
175
    #[arg(long, short)]
176
    format: Option<LtsFormat>,
177

            
178
    /// The filename of the modal formula
179
    formula_filename: PathBuf,
180

            
181
    /// The parity game output filename
182
    output: PathBuf,
183
}
184

            
185
/// Translate a feature transition system and a modal formula into a variability parity game
186
#[derive(clap::Args, Debug)]
187
struct TranslateVpgArgs {
188
    /// The filename of the feature diagram
189
    feature_diagram_filename: PathBuf,
190

            
191
    /// The input featured transition system file format
192
    #[arg(long, short)]
193
    format: Option<LtsFormat>,
194

            
195
    /// The filename of the feature transition system
196
    fts_filename: PathBuf,
197

            
198
    /// The filename of the modal formula
199
    formula_filename: PathBuf,
200

            
201
    /// The variability parity game output filename
202
    output: String,
203
}
204

            
205
/// Display a (variability) parity game
206
#[derive(clap::Args, Debug)]
207
struct DisplayArgs {
208
    /// The filename of the (variability) parity game to display
209
    filename: PathBuf,
210

            
211
    /// The .dot file output filename
212
    output: PathBuf,
213

            
214
    /// The parity game file format
215
    #[arg(long, short)]
216
    format: Option<ParityGameFormat>,
217
}
218

            
219
fn main() -> Result<ExitCode, MercError> {
220
    let cli = Cli::parse();
221

            
222
    let mut timing = Timing::new();
223

            
224
    env_logger::Builder::new()
225
        .filter_level(cli.verbosity.log_level_filter())
226
        .format_key_values(|formatter, source| format_key_values_json(formatter, source))
227
        .parse_default_env()
228
        .init();
229

            
230
    if cli.version.into() {
231
        eprintln!("{}", Version);
232
        return Ok(ExitCode::SUCCESS);
233
    }
234

            
235
    if let Some(command) = &cli.commands {
236
        match command {
237
            Commands::Solve(args) => handle_solve(&cli, args, &mut timing)?,
238
            Commands::Reachable(args) => handle_reachable(&cli, args, &mut timing)?,
239
            Commands::Project(args) => handle_project(&cli, args, &mut timing)?,
240
            Commands::ProjectVpg(args) => handle_project_vpg(&cli, args, &mut timing)?,
241
            Commands::Translate(args) => handle_translate(args)?,
242
            Commands::TranslateVpg(args) => handle_translate_vpg(&cli, args)?,
243
            Commands::Display(args) => handle_display(&cli, args, &mut timing)?,
244
        }
245
    }
246

            
247
    if cli.timings {
248
        timing.print();
249
    }
250

            
251
    print_allocator_metrics();
252
    if cfg!(feature = "merc_metrics") {
253
        oxidd::bdd::print_stats();
254
    }
255
    Ok(ExitCode::SUCCESS)
256
}
257

            
258
/// Handle the `solve` subcommand.
259
///
260
/// Reads either a standard parity game (PG) or a variability parity game (VPG)
261
/// based on the provided format or filename extension, then solves it using
262
/// Zielonka's algorithm.
263
fn handle_solve(cli: &Cli, args: &SolveArgs, timing: &mut Timing) -> Result<(), MercError> {
264
    let path = Path::new(&args.filename);
265
    let mut file = File::open(path)?;
266
    let format = guess_format_from_extension(path, args.format)
267
        .ok_or_else(|| format!("Unknown parity game file format for '{}", path.display()))?;
268

            
269
    if format == ParityGameFormat::PG {
270
        // Read and solve a standard parity game.
271
        let game = timing.measure("read_pg", || read_pg(&mut file))?;
272

            
273
        let (solution, _strategy) = timing.measure("solve_zielonka", || solve_zielonka(&game));
274
        if args.full_solution {
275
            for (index, player_set) in solution.iter().enumerate() {
276
                println!("W{index}: {}", player_set.iter_ones().format(", "));
277
            }
278
        } else if solution[0][0] {
279
            println!("{}", Player::Even.solution())
280
        } else {
281
            println!("{}", Player::Odd.solution())
282
        }
283
    } else {
284
        let solve_variant = args
285
            .solve_variant
286
            .ok_or("For variability parity game solving a solving strategy should be selected")?;
287

            
288
        // Read and solve a variability parity game.
289
        let manager_ref = oxidd::bdd::new_manager(
290
            cli.oxidd_node_capacity,
291
            cli.oxidd_cache_capacity.unwrap_or(cli.oxidd_node_capacity),
292
            cli.oxidd_workers,
293
        );
294

            
295
        let game = timing.measure("read_vpg", || -> Result<_, MercError> {
296
            read_vpg(&manager_ref, &mut file)
297
        })?;
298

            
299
        let game = if !game.is_total(&manager_ref)? {
300
            info!("Making the VPG total...");
301
            make_vpg_total(&manager_ref, &game)?
302
        } else {
303
            game
304
        };
305

            
306
        timing.measure("solve_variability_zielonka", || -> Result<_, MercError> {
307
            if solve_variant == ZielonkaVariant::Product {
308
                // Since we want to print W0, W1 separately, we need to store the results temporarily.
309
                let mut results = [Vec::new(), Vec::new()];
310
                for result in solve_variability_product_zielonka(&game, timing) {
311
                    let (cube, _bdd, solution) = result?;
312

            
313
                    for (index, w) in solution.iter().enumerate() {
314
                        results[index].push((cube.clone(), w.clone()));
315
                    }
316
                }
317

            
318
                for (index, w) in results.iter().enumerate() {
319
                    for (cube, vertices) in w {
320
                        println!(
321
                            "W{index}: For product {} the following vertices are in: {}",
322
                            FormatConfig(cube),
323
                            vertices
324
                                .iter_ones()
325
                                .filter(|v| if args.full_solution { true } else { *v == 0 })
326
                                .format(", ")
327
                        );
328
                    }
329
                }
330
            } else {
331
                let solutions = solve_variability_zielonka(&manager_ref, &game, solve_variant, false)?;
332
                for (index, w) in solutions.iter().enumerate() {
333
                    for entry in CubeIterAll::new(game.configuration()) {
334
                        let config = entry?;
335
                        let config_function = bits_to_bdd(&manager_ref, game.variables(), &config)?;
336

            
337
                        println!(
338
                            "W{index}: For product {} the following vertices are in: {}",
339
                            FormatConfig(&config),
340
                            w.iter() // Do not use iter_vertices because the first one is the initial vertex only
341
                                .take(if args.full_solution { usize::MAX } else { 1 }) // Take only first if we don't want full solution
342
                                .filter(|(_v, config)| { config.and(&config_function).unwrap().satisfiable() })
343
                                .map(|(v, _)| v)
344
                                .format(", ")
345
                        );
346
                    }
347
                }
348

            
349
                if args.verify_solution {
350
                    verify_variability_product_zielonka_solution(&game, &solutions, timing)?;
351
                }
352
            }
353

            
354
            Ok(())
355
        })?;
356
    }
357

            
358
    Ok(())
359
}
360

            
361
/// Handle the `reachable` subcommand.
362
///
363
/// Reads a PG or VPG, computes its reachable part, and writes it to `output`.
364
/// Also logs the vertex index mapping to aid inspection.
365
fn handle_reachable(cli: &Cli, args: &ReachableArgs, timing: &mut Timing) -> Result<(), MercError> {
366
    let path = Path::new(&args.filename);
367
    let format = guess_format_from_extension(path, args.format)
368
        .ok_or_else(|| format!("Unknown parity game file format for '{}", path.display()))?;
369

            
370
    let mut file = File::open(path)?;
371
    match format {
372
        ParityGameFormat::PG => {
373
            let game = timing.measure("read_pg", || read_pg(&mut file))?;
374

            
375
            let (reachable_game, mapping) = timing.measure("compute_reachable", || compute_reachable(&game));
376

            
377
            for (old_index, new_index) in mapping.iter().enumerate() {
378
                debug!("{} -> {:?}", old_index, new_index);
379
            }
380

            
381
            let mut output_file = File::create(&args.output)?;
382
            write_pg(&mut output_file, &reachable_game)?;
383
        }
384
        ParityGameFormat::VPG => {
385
            let manager_ref = oxidd::bdd::new_manager(
386
                cli.oxidd_node_capacity,
387
                cli.oxidd_cache_capacity.unwrap_or(cli.oxidd_node_capacity),
388
                cli.oxidd_workers,
389
            );
390

            
391
            let game = timing.measure("read_vpg", || read_vpg(&manager_ref, &mut file))?;
392
            let (reachable_game, mapping) = timing.measure("compute_reachable_vpg", || compute_reachable(&game));
393

            
394
            for (old_index, new_index) in mapping.iter().enumerate() {
395
                debug!("{} -> {:?}", old_index, new_index);
396
            }
397

            
398
            let mut output_file = File::create(&args.output)?;
399
            // Write reachable part using the PG writer, as reachable_game is a ParityGame.
400
            write_pg(&mut output_file, &reachable_game)?;
401
        }
402
    }
403

            
404
    Ok(())
405
}
406

            
407
/// Projects a feature transition system to a set of transition systems and writes them to output.
408
fn handle_project(cli: &Cli, args: &ProjectArgs, timing: &mut Timing) -> Result<(), MercError> {
409
    let format = guess_lts_format_from_extension(&args.filename, args.format)
410
        .ok_or_else(|| format!("Unknown featured transition system format for '{}'.", args.filename.display()))?;
411

            
412
    if format != LtsFormat::Aut {
413
        return Err(MercError::from(
414
            "The project command only works for featured transition systems in the .aut format.",
415
        ));
416
    }
417
    
418
    // Read and solve a variability parity game.
419
    let manager_ref = oxidd::bdd::new_manager(
420
        cli.oxidd_node_capacity,
421
        cli.oxidd_cache_capacity.unwrap_or(cli.oxidd_node_capacity),
422
        cli.oxidd_workers,
423
    );
424

            
425
    // Read feature diagram
426
    let mut feature_diagram_file = File::open(&args.feature_diagram_filename).map_err(|e| {
427
        MercError::from(format!(
428
            "Could not open feature diagram file '{}': {}",
429
            &args.feature_diagram_filename.display(), e
430
        ))
431
    })?;
432
    let feature_diagram = FeatureDiagram::from_reader(&manager_ref, &mut feature_diagram_file)?;
433

            
434
    // Read the feature transition system.
435
    let mut fts_file = File::open(&args.filename)?;
436
    let fts = read_fts(&manager_ref, &mut fts_file, feature_diagram.features().clone())?;
437
    let output_path = Path::new(&args.output);    
438
        
439
    for result in project_feature_transition_system_iter(&fts, &feature_diagram, timing) {
440
        let (ProjectedLts { bits, bdd: _, lts }, _) = result?;
441

            
442
        let extension = output_path.extension().ok_or("Missing extension on output file")?;
443
        let new_path = output_path
444
            .with_file_name(format!(
445
                "{}_{}",
446
                output_path
447
                    .file_stem()
448
                    .ok_or("Missing filename on output")?
449
                    .to_string_lossy(),
450
                FormatConfig(&bits)
451
            ))
452
            .with_extension(extension);
453

            
454
        let mut output_file = File::create(new_path)?;
455

            
456
        write_aut(&mut output_file, &lts)?;
457
    }
458

            
459
    Ok(())    
460
}
461

            
462
/// Compute all the projections of a variability parity game and write them to output.
463
fn handle_project_vpg(cli: &Cli, args: &ProjectVpgArgs, timing: &mut Timing) -> Result<(), MercError> {
464
    let format = guess_format_from_extension(&args.filename, args.format)
465
        .ok_or_else(|| format!("Unknown parity game file format for '{}'.", args.filename.display()))?;
466

            
467
    let mut file = File::open(&args.filename)?;
468
    if format != ParityGameFormat::VPG {
469
        return Err(MercError::from(
470
            "The project command only works for variability parity games.",
471
        ));
472
    }
473

            
474
    // Read the variability parity game.
475
    let manager_ref = oxidd::bdd::new_manager(
476
        cli.oxidd_node_capacity,
477
        cli.oxidd_cache_capacity.unwrap_or(cli.oxidd_node_capacity),
478
        cli.oxidd_workers,
479
    );
480
    
481
    let vpg = timing.measure("read_vpg", || read_vpg(&manager_ref, &mut file))?;
482
    let output_path = Path::new(&args.output);
483

            
484
    for result in project_variability_parity_games_iter(&vpg, timing) {
485
        let (Projected { bits, bdd: _, game }, _) = result?;
486

            
487
        let extension = output_path.extension().ok_or("Missing extension on output file")?;
488
        let new_path = output_path
489
            .with_file_name(format!(
490
                "{}_{}",
491
                output_path
492
                    .file_stem()
493
                    .ok_or("Missing filename on output")?
494
                    .to_string_lossy(),
495
                FormatConfig(&bits)
496
            ))
497
            .with_extension(extension);
498

            
499
        let mut output_file = File::create(new_path)?;
500

            
501
        if args.reachable {
502
            let (reachable_pg, _projection) = compute_reachable(&game);
503
            write_pg(&mut output_file, &reachable_pg)?;
504
        } else {
505
            write_pg(&mut output_file, &game)?;
506
        }
507
    }
508

            
509
    Ok(())
510
}
511

            
512
/// Handle the `translate` subcommand.
513
///
514
/// Translates a feature diagram, a feature transition system (FTS), and a modal
515
/// formula into a variability parity game.
516
fn handle_translate(args: &TranslateArgs) -> Result<(), MercError> {
517
    let format = guess_lts_format_from_extension(&args.labelled_transition_system, args.format)
518
        .ok_or_else(|| format!("Unknown labelled transition system format for '{}'.", args.labelled_transition_system.display()))?;
519

            
520
    if format != LtsFormat::Aut {
521
        return Err(MercError::from(
522
            "The translate command only works for labelled transition systems in the .aut format.",
523
        ));
524
    }
525

            
526
    // Read LTS
527
    let mut lts_file = File::open(&args.labelled_transition_system).map_err(|e| {
528
        MercError::from(format!(
529
            "Could not open feature transition system file '{}': {}",
530
            &args.labelled_transition_system.display(), e
531
        ))
532
    })?;
533
    let lts = read_aut(&mut lts_file, Vec::new())?;
534

            
535
    // Read and validate formula (no actions/data specs supported here)
536
    let formula_spec = UntypedStateFrmSpec::parse(&read_to_string(&args.formula_filename).map_err(|e| {
537
        MercError::from(format!(
538
            "Could not open formula file '{}': {}",
539
            &args.formula_filename.display(), e
540
        ))
541
    })?)?;
542
    if !formula_spec.action_declarations.is_empty() {
543
        return Err(MercError::from("We do not support formulas with action declarations."));
544
    }
545

            
546
    if !formula_spec.data_specification.is_empty() {
547
        return Err(MercError::from("The formula must not contain a data specification."));
548
    }
549

            
550
    let vpg = translate(&lts, &formula_spec.formula)?;
551

            
552
    let mut output_file = File::create(&args.output)?;
553
    write_pg(&mut output_file, &vpg)?;
554

            
555
    Ok(())
556
}
557

            
558
/// Handle the `translate_vpg` subcommand.
559
///
560
/// Translates a feature diagram, a feature transition system (FTS), and a modal
561
/// formula into a variability parity game.
562
fn handle_translate_vpg(cli: &Cli, args: &TranslateVpgArgs) -> Result<(), MercError> {
563
    let manager_ref = oxidd::bdd::new_manager(
564
        cli.oxidd_node_capacity,
565
        cli.oxidd_cache_capacity.unwrap_or(cli.oxidd_node_capacity),
566
        cli.oxidd_workers,
567
    );
568

            
569
    // Read feature diagram
570
    let mut feature_diagram_file = File::open(&args.feature_diagram_filename).map_err(|e| {
571
        MercError::from(format!(
572
            "Could not open feature diagram file '{}': {}",
573
            &args.feature_diagram_filename.display(), e
574
        ))
575
    })?;
576
    let feature_diagram = FeatureDiagram::from_reader(&manager_ref, &mut feature_diagram_file)?;
577

            
578
    // Read FTS
579
    let mut fts_file = File::open(&args.fts_filename).map_err(|e| {
580
        MercError::from(format!(
581
            "Could not open feature transition system file '{}': {}",
582
            &args.fts_filename.display(), e
583
        ))
584
    })?;
585
    let fts = read_fts(&manager_ref, &mut fts_file, feature_diagram.features().clone())?;
586

            
587
    // Read and validate formula (no actions/data specs supported here)
588
    let formula_spec = UntypedStateFrmSpec::parse(&read_to_string(&args.formula_filename).map_err(|e| {
589
        MercError::from(format!(
590
            "Could not open formula file '{}': {}",
591
            &args.formula_filename.display(), e
592
        ))
593
    })?)?;
594
    if !formula_spec.action_declarations.is_empty() {
595
        return Err(MercError::from("We do not support formulas with action declarations."));
596
    }
597

            
598
    if !formula_spec.data_specification.is_empty() {
599
        return Err(MercError::from("The formula must not contain a data specification."));
600
    }
601

            
602
    let vpg = translate_vpg(
603
        &manager_ref,
604
        &fts,
605
        feature_diagram.configuration().clone(),
606
        &formula_spec.formula,
607
    )?;
608
    let mut output_file = File::create(&args.output)?;
609
    write_vpg(&mut output_file, &vpg)?;
610

            
611
    Ok(())
612
}
613

            
614
/// Handle the `display` subcommand.
615
///
616
/// Reads a PG or VPG and writes a Graphviz `.dot` representation to `output`.
617
/// If the `dot` tool is available, also generates a PDF (`output.pdf`).
618
fn handle_display(cli: &Cli, args: &DisplayArgs, timing: &mut Timing) -> Result<(), MercError> {
619
    let path = Path::new(&args.filename);
620
    let mut file = File::open(path)?;
621
    let format = guess_format_from_extension(path, args.format)
622
        .ok_or_else(|| format!("Unknown parity game file format for '{}'.", path.display()))?;
623

            
624
    if format == ParityGameFormat::PG {
625
        // Read and display a standard parity game.
626
        let game = timing.measure("read_pg", || read_pg(&mut file))?;
627

            
628
        let mut output_file = File::create(&args.output)?;
629
        write!(&mut output_file, "{}", PgDot::new(&game))?;
630
    } else {
631
        // Read and display a variability parity game.
632
        let manager_ref = oxidd::bdd::new_manager(
633
            cli.oxidd_node_capacity,
634
            cli.oxidd_cache_capacity.unwrap_or(cli.oxidd_node_capacity),
635
            cli.oxidd_workers,
636
        );
637

            
638
        let game = timing.measure("read_vpg", || read_vpg(&manager_ref, &mut file))?;
639

            
640
        let mut output_file = File::create(&args.output)?;
641
        write!(&mut output_file, "{}", VpgDot::new(&game))?;
642
    }
643

            
644
    if let Ok(dot_path) = which::which("dot") {
645
        info!("Generating PDF using dot...");
646
        cmd!(dot_path, "-Tpdf", &args.output, "-O").run()?;
647
    }
648

            
649
    Ok(())
650
}