[go: up one dir, main page]

forc 0.3.3

Fuel Orchestrator.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
use crate::utils::manifest::Manifest;
use anyhow::{anyhow, bail, Context, Result};
use dirs::home_dir;
use flate2::read::GzDecoder;
use serde::{Deserialize, Serialize};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::io::Read;
use std::{
    collections::HashMap,
    fs,
    io::Cursor,
    path::{Path, PathBuf},
};
use sway_utils::constants;
use tar::Archive;

// A collection of remote dependency related functions

#[derive(Serialize, Deserialize, Debug)]
#[serde(untagged)]
pub enum Dependency {
    /// In the simple format, only a version is specified, eg.
    /// `package = "<version>"`
    Simple(String),
    /// The simple format is equivalent to a detailed dependency
    /// specifying only a version, eg.
    /// `package = { version = "<version>" }`
    Detailed(DependencyDetails),
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
pub struct DependencyDetails {
    pub(crate) version: Option<String>,
    pub(crate) path: Option<String>,
    pub(crate) git: Option<String>,
    pub(crate) branch: Option<String>,
}
pub enum OfflineMode {
    Yes,
    No,
}

impl From<bool> for OfflineMode {
    fn from(v: bool) -> OfflineMode {
        match v {
            true => OfflineMode::Yes,
            false => OfflineMode::No,
        }
    }
}

pub type GitHubAPICommitsResponse = Vec<GithubCommit>;

#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GithubCommit {
    pub sha: String,
}
/// VersionedDependencyDirectory holds the path to the directory where a given
/// GitHub-based dependency is installed and its respective git hash.
#[derive(Debug)]
pub struct VersionedDependencyDirectory {
    pub hash: String,
    pub path: PathBuf,
}

pub type GitHubRepoReleases = Vec<TaggedRelease>;

#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaggedRelease {
    #[serde(rename = "tag_name")]
    pub tag_name: String,
    #[serde(rename = "target_commitish")]
    pub target_commitish: String,
    pub name: String,
    pub draft: bool,
    pub prerelease: bool,
    #[serde(rename = "created_at")]
    pub created_at: String,
    #[serde(rename = "published_at")]
    pub published_at: String,
}

/// Downloads a non-local dependency that's hosted on GitHub.
/// By default, it stores the dependency in `~/.forc/`.
/// A given dependency `dep` is stored under `~/.forc/dep/default/$owner-$repo-$hash`.
/// If no hash (nor any other type of reference) is provided, Forc
/// will download the default branch at the latest commit.
/// If a branch is specified, it will go in `~/.forc/dep/$branch/$owner-$repo-$hash.
/// If a version is specified, it will go in `~/.forc/dep/$version/$owner-$repo-$hash.
/// Version takes precedence over branch reference.
pub fn download_github_dep(
    dep_name: &str,
    repo_base_url: &str,
    branch: &Option<String>,
    version: &Option<String>,
    offline_mode: OfflineMode,
) -> Result<String> {
    let home_dir = match home_dir() {
        None => return Err(anyhow!("Couldn't find home directory (`~/`)")),
        Some(p) => p.to_str().unwrap().to_owned(),
    };

    // hash the dep name into a number to avoid bad characters
    let mut s = DefaultHasher::new();
    dep_name.hash(&mut s);
    let hashed_dep_name = s.finish().to_string();

    // Version tag takes precedence over branch reference.
    let out_dir = match &version {
        Some(v) => PathBuf::from(format!(
            "{}/{}/{}/{}",
            home_dir,
            constants::FORC_DEPENDENCIES_DIRECTORY,
            hashed_dep_name,
            v
        )),
        // If no version specified, check if a branch was specified
        None => match &branch {
            Some(b) => PathBuf::from(format!(
                "{}/{}/{}/{}",
                home_dir,
                constants::FORC_DEPENDENCIES_DIRECTORY,
                hashed_dep_name,
                b
            )),
            // If no version and no branch, use default
            None => PathBuf::from(format!(
                "{}/{}/{}/default",
                home_dir,
                constants::FORC_DEPENDENCIES_DIRECTORY,
                hashed_dep_name
            )),
        },
    };

    // Check if dependency is already installed, if so, return its path.
    if out_dir.exists() {
        for entry in fs::read_dir(&out_dir)? {
            let path = entry?.path();
            // If the path to that dependency at that branch/version already
            // exists and there's a directory inside of it,
            // this directory should be the installation path.

            if path.is_dir() {
                return Ok(path.to_str().unwrap().to_string());
            }
        }
    }

    // If offline mode is enabled, don't proceed as it will
    // make use of the network to download the dependency from
    // GitHub.
    // If it's offline mode and the dependency already exists
    // locally, then it would've been returned in the block above.
    if let OfflineMode::Yes = offline_mode {
        return Err(anyhow!(
            "Can't build dependency: dependency {} doesn't exist locally and offline mode is enabled",
            dep_name
        ));
    }

    let github_api_url = build_github_repo_api_url(repo_base_url, branch, version);

    let _ = crate::utils::helpers::println_green(&format!(
        "  Downloading {:?} ({:?})",
        dep_name, out_dir
    ));

    match download_tarball(&github_api_url, &out_dir) {
        Ok(downloaded_dir) => Ok(downloaded_dir),
        Err(e) => Err(anyhow!("couldn't download from {}: {}", &github_api_url, e)),
    }
}

/// Builds a proper URL that's used to call GitHub's API.
/// The dependency is specified as `https://github.com/:owner/:project`
/// And the API URL must be like `https://api.github.com/repos/:owner/:project/tarball`
/// Adding a `:ref` at the end makes it download a branch/tag based repo.
/// Omitting it makes it download the default branch at latest commit.
pub fn build_github_repo_api_url(
    dependency_url: &str,
    branch: &Option<String>,
    version: &Option<String>,
) -> String {
    let dependency_url = dependency_url.trim_end_matches('/');
    let mut pieces = dependency_url.rsplit('/');

    let project_name: &str = match pieces.next() {
        Some(p) => p,
        None => dependency_url,
    };

    let owner_name: &str = match pieces.next() {
        Some(p) => p,
        None => dependency_url,
    };

    // Version tag takes precedence over branch reference.
    match version {
        Some(v) => {
            format!(
                "https://api.github.com/repos/{}/{}/tarball/{}",
                owner_name, project_name, v
            )
        }
        // If no version specified, check if a branch was specified
        None => match branch {
            Some(b) => {
                format!(
                    "https://api.github.com/repos/{}/{}/tarball/{}",
                    owner_name, project_name, b
                )
            }
            // If no version and no branch, download default branch at latest commit
            None => {
                format!(
                    "https://api.github.com/repos/{}/{}/tarball",
                    owner_name, project_name
                )
            }
        },
    }
}

pub fn download_tarball(url: &str, out_dir: &Path) -> Result<String> {
    let mut data = Vec::new();

    // Download the tarball.
    let handle = ureq::builder().user_agent("forc-builder").build();
    let resp = handle.get(url).call()?;
    resp.into_reader().read_to_end(&mut data)?;

    // Unpack the tarball.
    Archive::new(GzDecoder::new(Cursor::new(data)))
        .unpack(out_dir)
        .with_context(|| {
            format!(
                "failed to unpack tarball in directory: {}",
                out_dir.display()
            )
        })?;

    for entry in fs::read_dir(out_dir)? {
        let path = entry?.path();
        match path.is_dir() {
            true => return Ok(path.to_str().unwrap().to_string()),
            false => (),
        }
    }

    Err(anyhow!(
        "couldn't find downloaded dependency in directory: {}",
        out_dir.display(),
    ))
}

pub fn replace_dep_version(
    target_directory: &Path,
    git: &str,
    dep: &DependencyDetails,
) -> Result<()> {
    let current = get_current_dependency_version(target_directory)?;

    let api_url = build_github_repo_api_url(git, &dep.branch, &dep.version);
    download_tarball(&api_url, target_directory)?;

    // Delete old one
    match fs::remove_dir_all(current.path) {
        Ok(_) => Ok(()),
        Err(e) => {
            return Err(anyhow!(
                "failed to remove old version of the dependency ({}): {}",
                git,
                e
            ))
        }
    }
}

pub fn get_current_dependency_version(dep_dir: &Path) -> Result<VersionedDependencyDirectory> {
    let mut entries =
        fs::read_dir(dep_dir).context(format!("couldn't read directory {}", dep_dir.display()))?;
    let entry = match entries.next() {
        Some(entry) => entry,
        None => bail!("Dependency directory is empty. Run `forc build` to install dependencies."),
    };

    let path = entry?.path();
    if !path.is_dir() {
        bail!("{} isn't a directory.", dep_dir.display())
    }

    let file_name = path.file_name().unwrap();
    // Dependencies directories are named as "$repo_owner-$repo-$concatenated_hash"
    let hash = file_name
        .to_str()
        .with_context(|| format!("Invalid utf8 in dependency name: {}", path.display()))?
        .split('-')
        .last()
        .with_context(|| format!("Unexpected dependency naming scheme: {}", path.display()))?
        .into();
    Ok(VersionedDependencyDirectory { hash, path })
}

// Returns the _truncated_ (e.g `e6940e4`) latest commit hash of a
// GitHub repository given a branch. If branch is None, the default branch is used.
pub async fn get_latest_commit_sha(
    dependency_url: &str,
    branch: &Option<String>,
) -> Result<String> {
    // Quick protection against `git` dependency URL ending with `/`.
    let dependency_url = dependency_url.trim_end_matches('/');

    let mut pieces = dependency_url.rsplit('/');

    let project_name: &str = match pieces.next() {
        Some(p) => p,
        None => dependency_url,
    };

    let owner_name: &str = match pieces.next() {
        Some(p) => p,
        None => dependency_url,
    };

    let api_endpoint = match branch {
        Some(b) => {
            format!(
                "https://api.github.com/repos/{}/{}/commits?sha={}&per_page=1",
                owner_name, project_name, b
            )
        }
        None => {
            format!(
                "https://api.github.com/repos/{}/{}/commits?per_page=1",
                owner_name, project_name
            )
        }
    };

    let client = reqwest::Client::builder()
        .user_agent("forc-builder")
        .build()?;

    let resp = client.get(&api_endpoint).send().await?;

    let hash_vec = resp.json::<GitHubAPICommitsResponse>().await?;

    // `take(7)` because the truncated SHA1 used by GitHub is 7 chars long.
    let truncated_hash: String = hash_vec[0].sha.chars().take(7).collect();

    if truncated_hash.is_empty() {
        bail!(
            "failed to extract hash from GitHub commit history API, response: {:?}",
            hash_vec
        )
    }

    Ok(truncated_hash)
}

// Helper to get only detailed dependencies (`Dependency::Detailed`).
pub fn get_detailed_dependencies(manifest: &mut Manifest) -> HashMap<String, &DependencyDetails> {
    let mut dependencies: HashMap<String, &DependencyDetails> = HashMap::new();

    if let Some(ref mut deps) = manifest.dependencies {
        for (dep_name, dependency_details) in deps.iter_mut() {
            match dependency_details {
                Dependency::Simple(..) => continue,
                Dependency::Detailed(dep_details) => {
                    dependencies.insert(dep_name.to_owned(), dep_details)
                }
            };
        }
    }

    dependencies
}

pub async fn get_github_repo_releases(dependency_url: &str) -> Result<Vec<String>> {
    // Quick protection against `git` dependency URL ending with `/`.
    let dependency_url = dependency_url.trim_end_matches('/');

    let mut pieces = dependency_url.rsplit('/');

    let project_name: &str = match pieces.next() {
        Some(p) => p,
        None => dependency_url,
    };

    let owner_name: &str = match pieces.next() {
        Some(p) => p,
        None => dependency_url,
    };

    let api_endpoint = format!(
        "https://api.github.com/repos/{}/{}/releases",
        owner_name, project_name
    );

    let client = reqwest::Client::builder()
        .user_agent("forc-builder")
        .build()?;

    let resp = client.get(&api_endpoint).send().await?;

    let releases_vec = resp.json::<GitHubRepoReleases>().await?;

    let semver_releases: Vec<String> = releases_vec.iter().map(|r| r.tag_name.to_owned()).collect();

    Ok(semver_releases)
}