Skip to main content

packs/packs/
pack.rs

1use std::{
2    collections::{HashMap, HashSet},
3    fs::File,
4    hash::Hasher,
5    io::Read,
6    path::{Path, PathBuf},
7};
8
9use anyhow::Context;
10use core::hash::Hash;
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12use serde_yaml::Value;
13
14use super::{
15    checker::ViolationIdentifier, file_utils::expand_glob, ignored, PackageTodo,
16};
17
18#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)]
19pub struct Pack {
20    #[serde(skip)]
21    pub yml: PathBuf,
22
23    #[serde(skip)]
24    pub name: String,
25
26    #[serde(skip)]
27    pub relative_path: PathBuf,
28
29    #[serde(
30        default,
31        skip_serializing_if = "Option::is_none",
32        serialize_with = "serialize_checker_setting",
33        deserialize_with = "deserialize_checker_setting"
34    )]
35    pub enforce_dependencies: Option<CheckerSetting>,
36
37    #[serde(
38        default,
39        skip_serializing_if = "Option::is_none",
40        serialize_with = "serialize_checker_setting",
41        deserialize_with = "deserialize_checker_setting"
42    )]
43    pub enforce_privacy: Option<CheckerSetting>,
44
45    #[serde(
46        default,
47        skip_serializing_if = "Option::is_none",
48        serialize_with = "serialize_checker_setting",
49        deserialize_with = "deserialize_checker_setting"
50    )]
51    pub enforce_visibility: Option<CheckerSetting>,
52
53    #[serde(
54        default,
55        skip_serializing_if = "Option::is_none",
56        serialize_with = "serialize_checker_setting",
57        deserialize_with = "deserialize_checker_setting"
58    )]
59    pub enforce_layers: Option<CheckerSetting>,
60
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub owner: Option<String>,
63
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub layer: Option<String>,
66
67    #[serde(
68        default,
69        skip_serializing_if = "HashSet::is_empty",
70        serialize_with = "serialize_sorted_hashset_of_strings"
71    )]
72    pub dependencies: HashSet<String>,
73
74    #[serde(
75        default,
76        skip_serializing_if = "HashSet::is_empty",
77        serialize_with = "serialize_sorted_hashset_of_strings"
78    )]
79    pub ignored_dependencies: HashSet<String>,
80
81    #[serde(
82        default,
83        skip_serializing_if = "HashSet::is_empty",
84        serialize_with = "serialize_sorted_hashset_of_strings"
85    )]
86    pub ignored_private_constants: HashSet<String>,
87
88    #[serde(
89        default,
90        skip_serializing_if = "HashSet::is_empty",
91        serialize_with = "serialize_sorted_hashset_of_strings"
92    )]
93    pub private_constants: HashSet<String>,
94
95    #[serde(skip)]
96    pub package_todo: PackageTodo,
97
98    #[serde(
99        default,
100        skip_serializing_if = "Option::is_none",
101        serialize_with = "serialize_sorted_option_hashset_of_strings"
102    )]
103    pub visible_to: Option<HashSet<String>>,
104
105    #[serde(
106        default,
107        skip_serializing_if = "Option::is_none",
108        serialize_with = "serialize_checker_setting",
109        deserialize_with = "deserialize_checker_setting"
110    )]
111    pub enforce_folder_privacy: Option<CheckerSetting>,
112
113    #[serde(
114        default,
115        skip_serializing_if = "Option::is_none",
116        serialize_with = "serialize_checker_setting",
117        deserialize_with = "deserialize_checker_setting"
118    )]
119    pub enforce_folder_visibility: Option<CheckerSetting>, // deprecated
120
121    #[serde(skip_serializing_if = "is_default_public_folder")]
122    pub public_folder: Option<PathBuf>,
123
124    #[serde(flatten)]
125    pub client_keys: HashMap<String, Value>,
126
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub enforcement_globs_ignore: Option<Vec<EnforcementGlobsIgnore>>,
129}
130
131impl Hash for Pack {
132    fn hash<H: Hasher>(&self, state: &mut H) {
133        self.name.hash(state);
134    }
135}
136
137#[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize, Clone)]
138pub struct EnforcementGlobsIgnore {
139    #[serde(
140        default,
141        serialize_with = "serialize_sorted_hashset_of_strings",
142        skip_serializing_if = "HashSet::is_empty"
143    )]
144    pub enforcements: HashSet<String>,
145
146    #[serde(
147        default,
148        serialize_with = "serialize_sorted_hashset_of_strings",
149        skip_serializing_if = "HashSet::is_empty"
150    )]
151    pub ignores: HashSet<String>,
152
153    #[serde(default)]
154    pub reason: String,
155}
156
157#[derive(Debug, Default, PartialEq, Eq, Deserialize, Serialize, Clone)]
158pub enum CheckerSetting {
159    #[default]
160    False,
161    True,
162    Strict,
163}
164
165impl CheckerSetting {
166    pub fn is_false(&self) -> bool {
167        matches!(self, Self::False)
168    }
169
170    pub fn is_strict(&self) -> bool {
171        matches!(self, Self::Strict)
172    }
173}
174
175impl Pack {
176    pub fn last_name(&self) -> &str {
177        self.name.split('/').last().unwrap()
178    }
179
180    pub fn all_violations(&self) -> Vec<ViolationIdentifier> {
181        let mut violations = Vec::new();
182        let violations_by_pack = &self.package_todo.violations_by_defining_pack;
183        for (defining_pack_name, violation_groups) in violations_by_pack {
184            for (constant_name, violation_group) in violation_groups {
185                for violation_type in &violation_group.violation_types {
186                    for file in &violation_group.files {
187                        let identifier = ViolationIdentifier {
188                            violation_type: violation_type.clone(),
189                            strict: false,
190                            file: file.clone(),
191                            constant_name: constant_name.clone(),
192                            referencing_pack_name: self.name.clone(),
193                            defining_pack_name: defining_pack_name.clone(),
194                        };
195
196                        violations.push(identifier);
197                    }
198                }
199            }
200        }
201        violations
202    }
203
204    pub fn from_path(
205        package_yml_absolute_path: &Path,
206        absolute_root: &Path,
207    ) -> anyhow::Result<Pack> {
208        let mut yaml_contents = String::new();
209
210        let mut file = File::open(package_yml_absolute_path).map_err(|e| {
211            anyhow::Error::new(e).context(format!(
212                "Failed to open the YAML file at {:?}",
213                package_yml_absolute_path
214            ))
215        })?;
216
217        file.read_to_string(&mut yaml_contents).map_err(|e| {
218            anyhow::Error::new(e).context(format!(
219                "Failed to read the YAML file at {:?}",
220                package_yml_absolute_path
221            ))
222        })?;
223
224        let absolute_path_to_package_todo = package_yml_absolute_path
225            .parent()
226            .unwrap()
227            .join("package_todo.yml");
228
229        let package_todo: PackageTodo = if absolute_path_to_package_todo
230            .exists()
231        {
232            let mut package_todo_contents = String::new();
233            let mut file = File::open(&absolute_path_to_package_todo)
234                .context("Failed to open the package_todo.yml file")?;
235            file.read_to_string(&mut package_todo_contents)
236                .context("Could not read the package_todo.yml file")?;
237            serde_yaml::from_str(&package_todo_contents).with_context(|| {
238                format!(
239                    "Failed to deserialize the package_todo.yml file at {}. Try deleting the file and running the `update` command to regenerate it.",
240                    absolute_path_to_package_todo.display()
241                )
242            })?
243        } else {
244            PackageTodo::default()
245        };
246
247        Pack::from_contents(
248            package_yml_absolute_path,
249            absolute_root,
250            &yaml_contents,
251            package_todo,
252        )
253    }
254
255    pub fn from_contents(
256        package_yml_absolute_path: &Path,
257        absolute_root: &Path,
258        package_yml_contents: &str,
259        package_todo: PackageTodo,
260    ) -> anyhow::Result<Pack> {
261        let pack_result = serde_yaml::from_str(package_yml_contents);
262        let pack = match pack_result {
263            Ok(pack) => pack,
264            Err(e) => {
265                anyhow::bail!(
266                    "Failed to deserialize the YAML at {:?} with error: {:?}",
267                    package_yml_absolute_path,
268                    e
269                )
270            }
271        };
272
273        let package_yml_relative_path = package_yml_absolute_path
274            .strip_prefix(absolute_root)
275            .unwrap();
276        let mut relative_path = package_yml_relative_path
277            .parent()
278            .context("Expected package to be in a parent directory")?
279            .to_owned();
280
281        let mut name = relative_path
282            .to_str()
283            .context("Non-unicode characters?")?
284            .to_owned();
285        let yml = package_yml_absolute_path;
286
287        // Handle the root pack
288        if name == *"" {
289            name = String::from(".");
290            relative_path = PathBuf::from(".");
291        };
292
293        let pack: Pack = Pack {
294            yml: yml.to_path_buf(),
295            name,
296            relative_path,
297            package_todo,
298            ..pack
299        };
300
301        Ok(pack)
302    }
303
304    pub fn default_autoload_roots(&self) -> Vec<PathBuf> {
305        let root_pattern = self.yml.parent().unwrap().join("app").join("*");
306        let concerns_pattern = root_pattern.join("concerns");
307        let mut roots = expand_glob(root_pattern.to_str().unwrap());
308        roots.extend(expand_glob(concerns_pattern.to_str().unwrap()));
309
310        roots
311    }
312
313    pub fn relative_yml(&self) -> PathBuf {
314        self.relative_path.join("package.yml")
315    }
316
317    pub(crate) fn enforce_folder_privacy(&self) -> &CheckerSetting {
318        if self.enforce_folder_privacy.is_none() {
319            // enforce_folder_visibility is deprecated
320            match &self.enforce_folder_visibility {
321                Some(setting) => setting,
322                None => &CheckerSetting::False,
323            }
324        } else {
325            match &self.enforce_folder_privacy {
326                Some(setting) => setting,
327                None => &CheckerSetting::False,
328            }
329        }
330    }
331
332    pub(crate) fn public_folder(&self) -> PathBuf {
333        match &self.public_folder {
334            Some(folder) => folder.to_owned(),
335            None => self.relative_path.join("app/public"),
336        }
337    }
338
339    pub(crate) fn add_dependency(&self, to_pack: &Pack) -> Pack {
340        let mut new_pack = self.clone();
341        new_pack.dependencies.insert(to_pack.name.clone());
342        new_pack
343    }
344
345    pub(crate) fn ignores_for_enforcement(
346        &self,
347        enforcement: &str,
348    ) -> Option<&HashSet<String>> {
349        self.enforcement_globs_ignore.as_ref().and_then(|ignores| {
350            ignores
351                .iter()
352                .find(|ignore| ignore.enforcements.contains(enforcement))
353                .map(|ignore| &ignore.ignores)
354        })
355    }
356
357    pub(crate) fn is_ignored(
358        &self,
359        file_path: &str,
360        enforcement: &str,
361    ) -> anyhow::Result<bool> {
362        if let Some(ignore_rules) = self.ignores_for_enforcement(enforcement) {
363            return ignored::is_ignored(ignore_rules, file_path);
364        }
365        Ok(false)
366    }
367}
368
369fn serialize_sorted_hashset_of_strings<S>(
370    value: &HashSet<String>,
371    serializer: S,
372) -> Result<S::Ok, S::Error>
373where
374    S: Serializer,
375{
376    // Serialize in sorted order
377    let mut value: Vec<&String> = value.iter().collect();
378    value.sort();
379    value.serialize(serializer)
380}
381
382fn serialize_sorted_option_hashset_of_strings<S>(
383    value: &Option<HashSet<String>>,
384    serializer: S,
385) -> Result<S::Ok, S::Error>
386where
387    S: Serializer,
388{
389    match value {
390        Some(value) => serialize_sorted_hashset_of_strings(value, serializer),
391        None => serializer.serialize_none(),
392    }
393}
394
395fn is_default_public_folder(value: &Option<PathBuf>) -> bool {
396    match value {
397        Some(value) => value == &PathBuf::from("app/public"),
398        None => true,
399    }
400}
401
402const KEY_SORT_ORDER: &[&str] = &[
403    "enforce_dependencies",
404    "enforce_privacy",
405    "enforce_layers",
406    "enforce_visibility",
407    "enforce_folder_privacy",
408    "enforce_folder_visibility",
409    "enforce_architecture",
410    "layer",
411    "public_path",
412    "dependencies",
413    "owner",
414    "private_constants",
415    "visible_to",
416    "enforcement_globs_ignore",
417    "metadata",
418];
419
420pub fn serialize_pack(pack: &Pack) -> String {
421    let serialized: Value = serde_yaml::to_value(pack).unwrap();
422    let mapping = serialized.as_mapping().unwrap();
423
424    // Prepare a Vec to preserve order
425    let mut ordered_map: Vec<(String, Value)> = Vec::new();
426
427    // Add keys from KEY_SORT_ORDER
428    for key in KEY_SORT_ORDER {
429        if let Some(value) = mapping.get(Value::String(key.to_string())) {
430            ordered_map.push((key.to_string(), value.clone()));
431        }
432    }
433
434    // Add remaining keys not in KEY_SORT_ORDER
435    let mut added_keys: HashSet<String> =
436        ordered_map.iter().map(|(k, _)| k.clone()).collect();
437    for (key, value) in mapping {
438        if let Value::String(key_str) = key {
439            if !added_keys.contains(key_str) {
440                ordered_map.push((key_str.clone(), value.clone()));
441                added_keys.insert(key_str.clone());
442            }
443        }
444    }
445
446    // Convert the ordered map to a serde_yaml::Mapping
447    let mut sorted_mapping = serde_yaml::Mapping::new();
448    for (key, value) in ordered_map {
449        sorted_mapping.insert(Value::String(key), value);
450    }
451
452    // Serialize to YAML
453    let raw_yaml =
454        serde_yaml::to_string(&Value::Mapping(sorted_mapping)).unwrap();
455
456    // Remove YAML header (`---\n`) to match Ruby behavior
457    let trimmed_yaml = raw_yaml.trim_start_matches("---\n").to_string();
458
459    if trimmed_yaml == "{}\n" {
460        "".to_string()
461    } else {
462        trimmed_yaml
463    }
464}
465
466pub fn write_pack_to_disk(pack: &Pack) -> anyhow::Result<()> {
467    let serialized_pack = serialize_pack(pack);
468    let pack_dir = pack.yml.parent().ok_or_else(|| {
469        anyhow::Error::new(std::io::Error::new(
470            std::io::ErrorKind::NotFound,
471            format!("Failed to get parent directory of pack {:?}", &pack.yml),
472        ))
473    })?;
474
475    std::fs::create_dir_all(pack_dir).map_err(|e| {
476        anyhow::Error::new(e).context(format!(
477            "Failed to create directory for pack {:?}",
478            &pack_dir
479        ))
480    })?;
481
482    std::fs::write(&pack.yml, serialized_pack).map_err(|e| {
483        anyhow::Error::new(e)
484            .context(format!("Failed to write pack to disk {:?}", &pack.yml))
485    })?;
486
487    Ok(())
488}
489
490fn serialize_checker_setting<S>(
491    value: &Option<CheckerSetting>,
492    serializer: S,
493) -> Result<S::Ok, S::Error>
494where
495    S: Serializer,
496{
497    match value {
498        Some(CheckerSetting::False) => serializer.serialize_bool(false),
499        Some(CheckerSetting::True) => serializer.serialize_bool(true),
500        Some(CheckerSetting::Strict) => serializer.serialize_str("strict"),
501        None => serializer.serialize_none(),
502    }
503}
504
505fn deserialize_checker_setting<'de, D>(
506    deserializer: D,
507) -> Result<Option<CheckerSetting>, D::Error>
508where
509    D: Deserializer<'de>,
510{
511    // Deserialize an optional String
512    let s = String::deserialize(deserializer);
513
514    match s.unwrap().as_str() {
515        "false" => Ok(Some(CheckerSetting::False)),
516        "true" => Ok(Some(CheckerSetting::True)),
517        "strict" => Ok(Some(CheckerSetting::Strict)),
518        _ => Err(serde::de::Error::custom(
519            "expected one of: false, true, strict",
520        )),
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use crate::test_util;
527
528    use super::*;
529    use pretty_assertions::assert_eq;
530
531    fn reserialize_pack(pack_yml: &str) -> String {
532        let deserialized_pack = serde_yaml::from_str::<Pack>(pack_yml).unwrap();
533        serialize_pack(&deserialized_pack)
534    }
535
536    #[test]
537    fn test_serde_sorted_dependencies() {
538        let pack_yml = r#"
539# some comment
540dependencies:
541  - packs/c
542  - packs/a
543  - packs/b
544"#;
545
546        let actual = reserialize_pack(pack_yml);
547
548        let expected = r#"
549dependencies:
550- packs/a
551- packs/b
552- packs/c
553"#
554        .trim_start();
555
556        assert_eq!(expected, actual)
557    }
558
559    #[test]
560    fn test_serde_with_enforcements() {
561        let pack_yml = r#"
562# some comment
563enforce_privacy: true
564enforce_dependencies: strict
565dependencies:
566  - packs/c
567  - packs/a
568  - packs/b
569foobar: true
570"#;
571
572        let actual = reserialize_pack(pack_yml);
573
574        let expected = r#"
575enforce_dependencies: strict
576enforce_privacy: true
577dependencies:
578- packs/a
579- packs/b
580- packs/c
581foobar: true
582"#
583        .trim_start();
584
585        assert_eq!(expected, actual)
586    }
587
588    #[test]
589    fn test_serde_with_arbitrary_client_keys() {
590        let pack_yml = r#"
591# some comment
592dependencies:
593  - packs/c
594  - packs/a
595  - packs/b
596foobar: true
597"#;
598
599        let actual = reserialize_pack(pack_yml);
600
601        let expected = r#"
602dependencies:
603- packs/a
604- packs/b
605- packs/c
606foobar: true
607"#
608        .trim_start();
609
610        assert_eq!(expected, actual)
611    }
612
613    #[test]
614    fn test_serde_with_duplicate_dependencies() {
615        let pack_yml = r#"
616dependencies:
617  - packs/a
618  - packs/b
619  - packs/a
620  - packs/a
621  - packs/a
622"#;
623
624        let actual = reserialize_pack(pack_yml);
625
626        let expected = r#"
627dependencies:
628- packs/a
629- packs/b
630"#
631        .trim_start();
632
633        assert_eq!(expected, actual)
634    }
635
636    #[test]
637    fn test_serde_with_explicitly_empty_visible() {
638        let pack_yml = r#"
639visible_to:
640  - packs/c
641  - packs/a
642  - packs/b
643"#;
644
645        let actual = reserialize_pack(pack_yml);
646
647        let expected = r#"
648visible_to:
649- packs/a
650- packs/b
651- packs/c
652"#
653        .trim_start();
654
655        assert_eq!(expected, actual)
656    }
657
658    #[test]
659    fn test_serde_with_metadata() {
660        let pack_yml = r#"
661enforce_dependencies: false
662metadata:
663  foobar: true
664"#;
665
666        let actual = reserialize_pack(pack_yml);
667
668        let expected = r#"
669enforce_dependencies: false
670metadata:
671  foobar: true
672"#
673        .trim_start();
674
675        assert_eq!(expected, actual)
676    }
677
678    #[test]
679    fn test_serde_with_many_fields() {
680        let pack_yml = r#"
681enforce_dependencies: true
682enforce_privacy: true
683dependencies:
684- packs/utilities
685enforce_architecture: true
686"#;
687
688        let actual = reserialize_pack(pack_yml);
689
690        let expected = r#"
691enforce_dependencies: true
692enforce_privacy: true
693enforce_architecture: true
694dependencies:
695- packs/utilities
696"#
697        .trim_start();
698
699        assert_eq!(expected, actual)
700    }
701
702    #[test]
703    fn test_serde_with_owner() {
704        let pack_yml = r#"
705owner: Foobar
706enforce_dependencies: true
707"#;
708
709        let actual = reserialize_pack(pack_yml);
710
711        let expected = r#"
712enforce_dependencies: true
713owner: Foobar
714"#
715        .trim_start();
716
717        assert_eq!(expected, actual)
718    }
719
720    #[test]
721    fn test_serde_with_enforcement_globs() {
722        let pack_yml = r#"
723enforcement_globs_ignore:
724  - enforcements:
725      - privacy
726    ignores:
727      - "**/*"
728      - "!packs/foo"
729    reason: "deprecated foo"
730  - enforcements:
731      - layer
732    ignores:
733      - packs/bar
734    reason: "deprecated bar"
735        "#
736        .trim_start();
737
738        let pack: Result<Pack, _> = serde_yaml::from_str(pack_yml);
739        let pack = pack.unwrap();
740        assert_eq!(
741            pack.clone().enforcement_globs_ignore.unwrap(),
742            vec![
743                EnforcementGlobsIgnore {
744                    enforcements: ["privacy"]
745                        .iter()
746                        .map(|s| s.to_string())
747                        .collect(),
748                    ignores: ["**/*", "!packs/foo"]
749                        .iter()
750                        .map(|s| s.to_string())
751                        .collect(),
752                    reason: "deprecated foo".to_string(),
753                },
754                EnforcementGlobsIgnore {
755                    enforcements: ["layer"]
756                        .iter()
757                        .map(|s| s.to_string())
758                        .collect(),
759                    ignores: ["packs/bar"]
760                        .iter()
761                        .map(|s| s.to_string())
762                        .collect(),
763                    reason: "deprecated bar".to_string(),
764                },
765            ]
766        );
767
768        let reserialized = reserialize_pack(pack_yml);
769        let re_pack: Result<Pack, _> = serde_yaml::from_str(&reserialized);
770        let re_pack = re_pack.unwrap();
771        assert_eq!(pack, re_pack);
772
773        assert_eq!(
774            pack.ignores_for_enforcement("privacy"),
775            Some(&{
776                ["**/*", "!packs/foo"]
777                    .iter()
778                    .map(|s| s.to_string())
779                    .collect()
780            })
781        );
782        assert_eq!(pack.ignores_for_enforcement("nope"), None);
783    }
784
785    #[test]
786    fn test_serde_with_empty_pack() {
787        let pack_yml = r#""#;
788
789        let actual = reserialize_pack(pack_yml);
790
791        let expected = r#""#.trim_start();
792
793        assert_eq!(expected, actual)
794    }
795
796    #[test]
797    fn test_autoload_roots() {
798        let root = test_util::get_absolute_root(test_util::SIMPLE_APP);
799        let pack =
800            Pack::from_path(root.join("package.yml").as_path(), root.as_path());
801        assert!(pack.is_ok());
802
803        let actual = pack.unwrap().default_autoload_roots();
804        let expected =
805            vec![root.join("app/company_data"), root.join("app/services")];
806        assert_eq!(expected, actual)
807    }
808
809    #[test]
810    fn test_all_recorded_violations() -> anyhow::Result<()> {
811        let root = test_util::get_absolute_root(
812            "tests/fixtures/contains_package_todo",
813        );
814        let pack = Pack::from_path(
815            root.join("packs/foo/package.yml").as_path(),
816            root.as_path(),
817        )?;
818
819        let mut actual = pack.all_violations();
820        actual.sort_by(|a, b| a.file.cmp(&b.file));
821
822        let expected = vec![
823            ViolationIdentifier {
824                violation_type: "dependency".to_string(),
825                strict: false,
826                file: "packs/foo/app/services/foo.rb".to_string(),
827                constant_name: "::Bar".to_string(),
828                referencing_pack_name: "packs/foo".to_string(),
829                defining_pack_name: "packs/bar".to_string(),
830            },
831            ViolationIdentifier {
832                violation_type: "dependency".to_string(),
833                strict: false,
834                file: "packs/foo/app/services/other_foo.rb".to_string(),
835                constant_name: "::Bar".to_string(),
836                referencing_pack_name: "packs/foo".to_string(),
837                defining_pack_name: "packs/bar".to_string(),
838            },
839        ];
840
841        assert_eq!(expected, actual);
842
843        Ok(())
844    }
845}