petr_pkg/
lib.rs

1//! This crate contains all of the logic for reasoning
2//! about dependencies, pulling them in, creating lockfiles,
3//! etc.
4
5// TODO revisit this
6#![allow(dead_code)]
7pub mod manifest;
8
9use manifest::Manifest;
10use serde::{Deserialize, Serialize};
11use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
12
13pub mod error {
14    use thiserror::Error;
15    #[derive(Error, Debug)]
16    pub enum PkgError {
17        // TODO stop using generic errors and use codes instead
18        #[error("Pkg Error: {0}")]
19        Generic(String),
20        #[error(transparent)]
21        Io(#[from] std::io::Error),
22    }
23}
24
25#[derive(Debug, Serialize, Deserialize, Clone)]
26#[serde(untagged)]
27pub enum Dependency {
28    Git(GitDependency),
29    Path(PathDependency),
30}
31
32#[derive(Debug, Serialize, Deserialize)]
33pub struct Lockfile {
34    entries: Vec<LockfileEntry>,
35}
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct LockfileEntry {
38    name:       String,
39    hash:       String,
40    depends_on: BTreeMap<String, Dependency>,
41}
42
43#[derive(Debug, Serialize, Deserialize, Clone)]
44pub struct GitDependency {
45    pub git:    String,
46    pub branch: Option<String>,
47    pub tag:    Option<String>,
48    pub rev:    Option<String>,
49}
50
51#[derive(Debug, Serialize, Deserialize, Clone)]
52pub struct PathDependency {
53    pub path: String,
54}
55
56use std::{
57    collections::BTreeMap,
58    fs,
59    path::{Path, PathBuf},
60};
61
62/// An ordered list of dependencies to build in order.
63#[derive(Debug)]
64pub struct BuildPlan {
65    pub items: Vec<BuildableItem>,
66}
67
68#[derive(Debug)]
69pub struct BuildableItem {
70    pub path_to_source: PathBuf,
71    pub depends_on: Vec<DependencyKey>,
72    pub key: DependencyKey,
73    pub manifest: Manifest,
74}
75
76pub type DependencyKey = String;
77
78pub fn load_dependencies(deps: BTreeMap<String, Dependency>) -> Result<(Lockfile, BuildPlan), crate::error::PkgError> {
79    let mut entries = Vec::new();
80    let mut files = Vec::new();
81
82    let mut stdout = StandardStream::stdout(ColorChoice::Always);
83    // TODO should probably use dep_name instead of manifest.name so user
84    // can control how a library shows up in their code
85    for (dep_name, dep_source) in deps {
86        // TODO better styling for compilation prints
87        stdout.set_color(ColorSpec::new().set_fg(Some(Color::Green)).set_bold(true))?;
88        print!("Fetching ");
89        stdout.set_color(ColorSpec::new().set_fg(None).set_bold(false))?;
90        println!("{dep_name}");
91        let dep = match dep_source {
92            Dependency::Git(ref git_dep) => load_git_dependency(git_dep),
93            Dependency::Path(ref path_dep) => load_path_dependency(path_dep, path_dep.path.clone()),
94        }?;
95
96        entries.push(dep.lock.clone());
97        files.push(dep);
98    }
99
100    let lockfile = Lockfile { entries };
101    Ok((lockfile, order_dependencies(files)?))
102}
103
104fn order_dependencies(deps: Vec<LoadDependencyResult>) -> Result<BuildPlan, crate::error::PkgError> {
105    let mut graph: BTreeMap<DependencyKey, Vec<DependencyKey>> = BTreeMap::new();
106
107    for dep in &deps {
108        graph.entry(dep.key.clone()).or_default();
109        for top_level_dependency in dep.lock.depends_on.values() {
110            let key = match top_level_dependency {
111                Dependency::Git(git_dep) => git_dep.git.clone(),
112                Dependency::Path(path_dep) => path_dep.path.clone(),
113            };
114            graph.entry(dep.key.clone()).or_default().push(key);
115
116            // Recursively include transient dependencies
117            fn add_transient_dependencies(
118                graph: &mut BTreeMap<DependencyKey, Vec<DependencyKey>>,
119                dep_key: &DependencyKey,
120                dependency: &Dependency,
121                deps: &[LoadDependencyResult],
122            ) {
123                let key = match dependency {
124                    Dependency::Git(git_dep) => git_dep.git.clone(),
125                    Dependency::Path(path_dep) => path_dep.path.clone(),
126                };
127                graph.entry(dep_key.clone()).or_default().push(key.clone());
128
129                if let Some(dep) = deps.iter().find(|d| d.key == key) {
130                    for trans_dependency in dep.lock.depends_on.values() {
131                        add_transient_dependencies(graph, &key, trans_dependency, deps);
132                    }
133                }
134            }
135
136            add_transient_dependencies(&mut graph, &dep.key, top_level_dependency, &deps[..]);
137        }
138    }
139
140    for dep in &deps {
141        for dependency in dep.lock.depends_on.values() {
142            let dep_key = match dependency {
143                Dependency::Git(git_dep) => git_dep.git.parse().unwrap(),
144                Dependency::Path(path_dep) => path_dep.path.parse().unwrap(),
145            };
146            graph.entry(dep_key).or_default().push(dep.key.clone());
147        }
148    }
149
150    let mut sorted_keys = Vec::new();
151    let mut visited = BTreeMap::new();
152
153    fn visit(
154        node: DependencyKey,
155        graph: &BTreeMap<DependencyKey, Vec<DependencyKey>>,
156        visited: &mut BTreeMap<DependencyKey, bool>,
157        sorted_keys: &mut Vec<DependencyKey>,
158    ) {
159        if let Some(&is_visited) = visited.get(&node) {
160            if is_visited {
161                return;
162            }
163        }
164
165        visited.insert(node.clone(), true);
166
167        if let Some(neighbors) = graph.get(&node) {
168            for neighbor in neighbors.iter() {
169                visit(neighbor.clone(), graph, visited, sorted_keys);
170            }
171        }
172
173        sorted_keys.push(node);
174    }
175
176    for node in graph.keys() {
177        visit(node.clone(), &graph, &mut visited, &mut sorted_keys);
178    }
179
180    // load all transient deps, and combine them with `deps`
181    // then sort them by the order of the `sorted_keys`
182    let mut all_deps = deps.clone();
183    for dep in &deps {
184        for dependency in dep.lock.depends_on.values() {
185            let key = match dependency {
186                Dependency::Git(git_dep) => git_dep.git.clone(),
187                Dependency::Path(path_dep) => path_dep.path.clone(),
188            };
189            if !all_deps.iter().any(|d| d.key == key) {
190                let transient_dep = match dependency {
191                    Dependency::Git(git_dep) => load_git_dependency(git_dep),
192                    Dependency::Path(path_dep) => load_path_dependency(path_dep, key.clone()),
193                }?;
194                all_deps.push(transient_dep);
195            }
196        }
197    }
198
199    let items = sorted_keys
200        .into_iter()
201        .map(|key| {
202            let dep = all_deps.iter().find(|x| x.key == key).unwrap();
203            let path = Path::new(&dep.lock.name);
204            BuildableItem {
205                path_to_source: path.to_path_buf(),
206                depends_on: dep
207                    .lock
208                    .depends_on
209                    .values()
210                    .map(|dep| match dep {
211                        Dependency::Git(git_dep) => git_dep.git.clone(),
212                        Dependency::Path(path_dep) => path_dep.path.clone(),
213                    })
214                    .collect(),
215                key,
216                manifest: dep.manifest.clone(),
217            }
218        })
219        .collect();
220
221    Ok(BuildPlan { items })
222}
223
224#[derive(Clone)]
225struct LoadDependencyResult {
226    lock:     LockfileEntry,
227    files:    Vec<(String, String)>,
228    manifest: Manifest,
229    // a unique identifier for this dependency,
230    key:      String,
231}
232
233fn load_git_dependency(dep: &GitDependency) -> Result<LoadDependencyResult, error::PkgError> {
234    // determine an os-independent directory in `~/.petr` to store clones in. On windows this is `C:\Users\<user>\.petr`, on UNIX systems this is `~/.petr`
235    // etc.
236    // clone the git repository into the directory, then read all files in the directory (potentially using load_path_dependency)
237    // calculate the LockfileEntry
238    let home_dir = dirs::home_dir().expect("Failed to get home directory");
239    let petr_dir = home_dir.join(".petr");
240    if !petr_dir.exists() {
241        fs::create_dir_all(&petr_dir).expect("Failed to create .petr directory");
242    }
243
244    let repo_dir = petr_dir.join(dep.git.replace('/', "_"));
245
246    if !repo_dir.exists() {
247        let _ = git2::Repository::clone(&dep.git, &repo_dir).expect("Failed to clone Git repository");
248    }
249
250    let path_dep = PathDependency {
251        path: repo_dir.to_string_lossy().into_owned(),
252    };
253
254    load_path_dependency(&path_dep, dep.git.clone())
255}
256
257fn load_path_dependency(
258    dep: &PathDependency,
259    key: DependencyKey,
260) -> Result<LoadDependencyResult, error::PkgError> {
261    let path = Path::new(&dep.path).join("src");
262    let entries = fs::read_dir(path).unwrap_or_else(|_| panic!("Failed to read directory: {}", dep.path));
263
264    let files: Vec<_> = entries
265        .filter_map(|entry| {
266            let entry = entry.ok()?;
267            let path = entry.path();
268            if path.is_file() {
269                let file_name = path.file_name()?.to_string_lossy().into_owned();
270                let content = fs::read_to_string(&path).ok()?;
271                Some((file_name, content))
272            } else {
273                None
274            }
275        })
276        .collect();
277
278    // TODO(alex) canonicalize path dependencies so they are relative to the pete.toml file
279    let petr_toml_path = Path::new(&dep.path).join("pete.toml");
280    let manifest_content = fs::read_to_string(&petr_toml_path)
281        .map_err(|e| error::PkgError::Generic(format!("Could not read petr.toml file at {petr_toml_path:?}: {e:?}")))?;
282    let manifest: Manifest = toml::from_str(&manifest_content).expect("Failed to parse pete.toml");
283
284    let lockfile_entry = LockfileEntry {
285        name:       dep.path.clone(),
286        hash:       calculate_lockfile_hash(files.clone()),
287        depends_on: manifest.dependencies.clone(),
288    };
289
290    Ok(LoadDependencyResult {
291        lock: lockfile_entry,
292        files,
293        manifest,
294        key,
295    })
296}
297
298fn calculate_lockfile_hash(sources: Vec<(String, String)>) -> String {
299    // hash all sources and contents into a u128
300    use bcrypt::{hash, DEFAULT_COST};
301
302    let mut hasher = blake3::Hasher::new();
303    for (name, content) in sources {
304        hasher.update(name.as_bytes());
305        hasher.update(content.as_bytes());
306    }
307    let hash_bytes = hasher.finalize();
308    let hash_bytes = hash_bytes.as_bytes();
309    let hash_string = hash(hash_bytes, DEFAULT_COST).expect("Failed to hash");
310    u128::from_be_bytes(hash_string.as_bytes()[..16].try_into().expect("Failed to convert hash to u128")).to_string()
311}