packs/
packs.rs

1// Currently there are no supported library APIs for packs. The public API is the CLI.
2// This may change in the future! Please file an issue if you have a use case for a library API.
3pub mod cli;
4
5// Module declarations
6pub(crate) mod bin_locater;
7pub(crate) mod caching;
8pub(crate) mod checker;
9pub(crate) mod configuration;
10pub(crate) mod constant_resolver;
11pub(crate) mod dependencies;
12pub(crate) mod ignored;
13pub(crate) mod monkey_patch_detection;
14pub mod pack;
15pub(crate) mod parsing;
16pub(crate) mod raw_configuration;
17pub(crate) mod walk_directory;
18
19mod constant_dependencies;
20mod file_utils;
21mod logger;
22mod pack_set;
23mod package_todo;
24mod reference_extractor;
25
26use crate::packs;
27use crate::packs::pack::write_pack_to_disk;
28use crate::packs::pack::Pack;
29
30// Internal imports
31pub(crate) use self::checker::Violation;
32pub(crate) use self::pack_set::PackSet;
33pub(crate) use self::parsing::process_files_with_cache;
34pub(crate) use self::parsing::ruby::experimental::get_experimental_constant_resolver;
35pub(crate) use self::parsing::ruby::zeitwerk::get_zeitwerk_constant_resolver;
36pub(crate) use self::parsing::ParsedDefinition;
37pub(crate) use self::parsing::UnresolvedReference;
38use anyhow::bail;
39pub(crate) use configuration::Configuration;
40pub(crate) use package_todo::PackageTodo;
41
42// External imports
43use anyhow::Context;
44use serde::Deserialize;
45use serde::Serialize;
46use std::path::{Path, PathBuf};
47
48pub fn greet() {
49    println!("👋 Hello! Welcome to packs 📦 🔥 🎉 🌈. This tool is under construction.")
50}
51
52pub fn init(absolute_root: &Path, use_packwerk: bool) -> anyhow::Result<()> {
53    let command = if use_packwerk { "packwerk" } else { "pks" };
54    let root_package = format!("\
55# This file represents the root package of the application
56# Please validate the configuration using `{} validate` (for Rails applications) or running the auto generated
57# test case (for non-Rails projects). You can then use `{} check` to check your code.
58
59# Change to `true` to turn on dependency checks for this package
60enforce_dependencies: false
61
62# A list of this package's dependencies
63# Note that packages in this list require their own `package.yml` file
64# dependencies:
65# - \"packages/billing\"
66", command, command);
67
68    let packs_config = "\
69# See: Setting up the configuration file
70# https://github.com/Shopify/packwerk/blob/main/USAGE.md#configuring-packwerk
71
72# List of patterns for folder paths to include
73# include:
74# - \"**/*.{rb,rake,erb}\"
75
76# List of patterns for folder paths to exclude
77# exclude:
78# - \"{bin,node_modules,script,tmp,vendor}/**/*\"
79
80# Patterns to find package configuration files
81# package_paths: \"**/\"
82
83# List of custom associations, if any
84# custom_associations:
85# - \"cache_belongs_to\"
86
87# Whether or not you want the cache enabled (disabled by default)
88# cache: true
89
90# Where you want the cache to be stored (default below)
91# cache_directory: \"tmp/cache/packwerk\"
92";
93    let root_package_path = absolute_root.join("package.yml");
94    let packs_config_path = absolute_root.join(if use_packwerk {
95        "packwerk.yml"
96    } else {
97        "packs.yml"
98    });
99
100    if root_package_path.exists() {
101        println!("`{}` already exists!", root_package_path.display());
102        bail!("Could not initialize package.yml")
103    }
104    if packs_config_path.exists() {
105        println!("`{}` already exists!", packs_config_path.display());
106        bail!(format!(
107            "Could not initialize {}",
108            packs_config_path.display()
109        ))
110    }
111
112    std::fs::write(root_package_path.clone(), root_package).unwrap();
113    std::fs::write(packs_config_path.clone(), packs_config).unwrap();
114
115    println!(
116        "Created '{}' and '{}'",
117        packs_config_path.display(),
118        root_package_path.display()
119    );
120    Ok(())
121}
122
123fn create(configuration: &Configuration, name: String) -> anyhow::Result<()> {
124    let existing_pack = configuration.pack_set.for_pack(&name);
125    if existing_pack.is_ok() {
126        println!("`{}` already exists!", &name);
127        return Ok(());
128    }
129    let new_pack_path =
130        configuration.absolute_root.join(&name).join("package.yml");
131
132    let new_pack = Pack::from_contents(
133        &new_pack_path,
134        &configuration.absolute_root,
135        "enforce_dependencies: true",
136        PackageTodo::default(),
137    )?;
138
139    write_pack_to_disk(&new_pack)?;
140
141    let readme = format!(
142"Welcome to `{}`!
143
144If you're the author, please consider replacing this file with a README.md, which may contain:
145- What your pack is and does
146- How you expect people to use your pack
147- Example usage of your pack's public API and where to find it
148- Limitations, risks, and important considerations of usage
149- How to get in touch with eng and other stakeholders for questions or issues pertaining to this pack
150- What SLAs/SLOs (service level agreements/objectives), if any, your package provides
151- When in doubt, keep it simple
152- Anything else you may want to include!
153
154README.md should change as your public API changes.
155
156See https://github.com/rubyatscale/packs#readme for more info!",
157    new_pack.name
158);
159
160    let readme_path = configuration.absolute_root.join(&name).join("README.md");
161    std::fs::write(readme_path, readme).context("Failed to write README.md")?;
162
163    println!("Successfully created `{}`!", name);
164    Ok(())
165}
166
167pub fn check(
168    configuration: &Configuration,
169    files: Vec<String>,
170) -> anyhow::Result<()> {
171    let result = checker::check_all(configuration, files)
172        .context("Failed to check files")?;
173    println!("{}", result);
174    if result.has_violations() {
175        bail!("Violations found!")
176    }
177    Ok(())
178}
179
180pub fn update(configuration: &Configuration) -> anyhow::Result<()> {
181    // Debug log configuration if ENV variable PACKS_DEBUG is set
182    if std::env::var("PACKS_DEBUG").is_ok() {
183        println!("Configuration: {:#?}", configuration);
184    }
185    checker::update(configuration)
186}
187
188pub fn add_dependency(
189    configuration: &Configuration,
190    from: String,
191    to: String,
192) -> anyhow::Result<()> {
193    let pack_set = &configuration.pack_set;
194
195    let from_pack = pack_set
196        .for_pack(&from)
197        .context(format!("`{}` not found", from))?;
198
199    let to_pack = pack_set
200        .for_pack(&to)
201        .context(format!("`{}` not found", to))?;
202
203    // Print a warning if the dependency already exists
204    if from_pack.dependencies.contains(&to_pack.name) {
205        println!(
206            "`{}` already depends on `{}`!",
207            from_pack.name, to_pack.name
208        );
209        return Ok(());
210    }
211
212    let new_from_pack = from_pack.add_dependency(to_pack);
213
214    write_pack_to_disk(&new_from_pack)?;
215
216    // Note: Ideally we wouldn't have to refetch the configuration and could instead
217    // either update the existing one OR modify the existing one and return a new one
218    // (which takes ownership over the previous one).
219    // For now, we simply refetch the entire configuration for simplicity,
220    // since we don't mind the slowdown for this CLI command.
221    let new_configuration = configuration::get(
222        &configuration.absolute_root,
223        &configuration.input_files_count,
224    )?;
225    let validation_result = packs::validate(&new_configuration);
226    if validation_result.is_err() {
227        println!("Added `{}` as a dependency to `{}`!", to, from);
228        println!("Warning: This creates a cycle!");
229    } else {
230        println!("Successfully added `{}` as a dependency to `{}`!", to, from);
231    }
232
233    Ok(())
234}
235
236pub fn list_included_files(configuration: Configuration) -> anyhow::Result<()> {
237    configuration
238        .included_files
239        .iter()
240        .for_each(|f| println!("{}", f.display()));
241    Ok(())
242}
243
244pub fn validate(configuration: &Configuration) -> anyhow::Result<()> {
245    checker::validate_all(configuration)
246}
247
248pub fn configuration(
249    project_root: PathBuf,
250    input_files_count: &usize,
251) -> anyhow::Result<Configuration> {
252    let absolute_root = project_root.canonicalize()?;
253    configuration::get(&absolute_root, input_files_count)
254}
255
256pub fn check_unnecessary_dependencies(
257    configuration: &Configuration,
258    auto_correct: bool,
259) -> anyhow::Result<()> {
260    if auto_correct {
261        checker::remove_unnecessary_dependencies(configuration)
262    } else {
263        checker::check_unnecessary_dependencies(configuration)
264    }
265}
266
267pub fn add_dependencies(
268    configuration: &Configuration,
269    pack_name: &str,
270) -> anyhow::Result<()> {
271    checker::add_all_dependencies(configuration, pack_name)
272}
273
274pub fn update_dependencies_for_constant(
275    configuration: &Configuration,
276    constant_name: &str,
277) -> anyhow::Result<()> {
278    match constant_dependencies::update_dependencies_for_constant(
279        configuration,
280        constant_name,
281    ) {
282        Ok(num_updated) => {
283            match num_updated {
284                0 => println!(
285                    "No dependencies to update for constant '{}'",
286                    constant_name
287                ),
288                1 => println!(
289                    "Successfully updated 1 dependency for constant '{}'",
290                    constant_name
291                ),
292                _ => println!(
293                    "Successfully updated {} dependencies for constant '{}'",
294                    num_updated, constant_name
295                ),
296            }
297            Ok(())
298        }
299        Err(err) => Err(anyhow::anyhow!(err)),
300    }
301}
302
303pub fn list(configuration: Configuration) {
304    for pack in configuration.pack_set.packs {
305        println!("{}", pack.yml.display())
306    }
307}
308
309pub fn lint_package_yml_files(
310    configuration: &Configuration,
311) -> anyhow::Result<()> {
312    for pack in &configuration.pack_set.packs {
313        write_pack_to_disk(pack)?
314    }
315    Ok(())
316}
317
318pub fn delete_cache(configuration: Configuration) {
319    let absolute_cache_dir = configuration.cache_directory;
320    if let Err(err) = std::fs::remove_dir_all(&absolute_cache_dir) {
321        eprintln!(
322            "Failed to remove {}: {}",
323            &absolute_cache_dir.display(),
324            err
325        );
326    }
327}
328
329#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
330pub struct ProcessedFile {
331    pub absolute_path: PathBuf,
332    pub unresolved_references: Vec<UnresolvedReference>,
333    pub definitions: Vec<ParsedDefinition>,
334
335    #[serde(default)] // Default to an empty Vec if not present
336    pub sigils: Vec<Sigil>,
337}
338
339// A sigil is a way to specify some packs specific behavior at the top of a file, like
340// `# pack_public: true`. This struct picks up sigil names, which are an enum of string values, starting with just one possibility.
341// and value, which is a boolean
342#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
343pub struct Sigil {
344    pub name: String,
345    pub value: bool,
346}
347
348#[derive(Debug, PartialEq, Serialize, Deserialize, Default, Eq, Clone)]
349pub struct SourceLocation {
350    line: usize,
351    column: usize,
352}
353
354pub(crate) fn list_definitions(
355    configuration: &Configuration,
356    ambiguous: bool,
357) -> anyhow::Result<()> {
358    let constant_resolver = if configuration.experimental_parser {
359        let processed_files: Vec<ProcessedFile> = process_files_with_cache(
360            &configuration.included_files,
361            configuration.get_cache(),
362            configuration,
363        )?;
364
365        get_experimental_constant_resolver(
366            &configuration.absolute_root,
367            &processed_files,
368            &configuration.ignored_definitions,
369        )
370    } else {
371        if ambiguous {
372            bail!("Ambiguous mode is not supported for the Zeitwerk parser");
373        }
374        get_zeitwerk_constant_resolver(
375            &configuration.pack_set,
376            &configuration.constant_resolver_configuration(),
377        )
378    };
379
380    let constant_definition_map = constant_resolver
381        .fully_qualified_constant_name_to_constant_definition_map();
382
383    for (name, definitions) in constant_definition_map {
384        if ambiguous && definitions.len() == 1 {
385            continue;
386        }
387
388        for definition in definitions {
389            let relative_path = definition
390                .absolute_path_of_definition
391                .strip_prefix(&configuration.absolute_root)?;
392
393            println!("{:?} is defined at {:?}", name, relative_path);
394        }
395    }
396    Ok(())
397}
398
399fn expose_monkey_patches(
400    configuration: &Configuration,
401    rubydir: &PathBuf,
402    gemdir: &PathBuf,
403) -> anyhow::Result<()> {
404    println!(
405        "{}",
406        monkey_patch_detection::expose_monkey_patches(
407            configuration,
408            rubydir,
409            gemdir,
410        )?
411    );
412    Ok(())
413}
414
415fn list_dependencies(
416    configuration: &Configuration,
417    pack_name: String,
418) -> anyhow::Result<()> {
419    println!("Pack dependencies for {}\n", pack_name);
420    let dependencies =
421        dependencies::find_dependencies(configuration, &pack_name)?;
422    println!("Explicit ({}):", dependencies.explicit.len());
423    if dependencies.explicit.is_empty() {
424        println!("- None");
425    } else {
426        for dependency in dependencies.explicit {
427            println!("- {}", dependency);
428        }
429    }
430    println!("\nImplicit (violations) ({}):", dependencies.implicit.len());
431    if dependencies.implicit.is_empty() {
432        println!("- None");
433    } else {
434        let mut dependent_packs_with_violations =
435            dependencies.implicit.keys().collect::<Vec<_>>();
436        dependent_packs_with_violations.sort();
437        for dependent in dependent_packs_with_violations {
438            println!("- {}", dependent);
439            for (violation_type, count) in &dependencies.implicit[dependent] {
440                println!("  - {}: {}", violation_type, count);
441            }
442        }
443    }
444    Ok(())
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use std::path::PathBuf;
451
452    #[test]
453    fn test_for_file() {
454        let configuration = configuration::get(
455            PathBuf::from("tests/fixtures/simple_app")
456                .canonicalize()
457                .expect("Could not canonicalize path")
458                .as_path(),
459            &10,
460        )
461        .unwrap();
462        let absolute_file_path = configuration
463            .absolute_root
464            .join("packs/foo/app/services/foo.rb")
465            .canonicalize()
466            .expect("Could not canonicalize path");
467
468        assert_eq!(
469            String::from("packs/foo"),
470            configuration
471                .pack_set
472                .for_file(&absolute_file_path)
473                .unwrap()
474                .unwrap()
475                .name
476        )
477    }
478}