Skip to main content

oseda_cli/cmd/
deploy.rs

1use std::{env, error::Error, fs, path::Path};
2
3use clap::Args;
4
5use crate::{
6    config,
7    github::{self, git},
8};
9
10/// Options for the `oseda deploy` command
11#[derive(Args, Debug)]
12pub struct DeployOptions {
13    fork_url: String,
14}
15
16struct SshUrl(String);
17
18/// string deref
19impl std::ops::Deref for SshUrl {
20    type Target = String;
21
22    fn deref(&self) -> &Self::Target {
23        &self.0
24    }
25}
26
27/// Convert a standard HTTPS GitHub URL to SSH format
28///
29/// # Arguments
30/// * `value` - a String starting with `https://github.com/...`
31///
32/// # Returns
33/// * `Ok(SshUrl)` if parsing succeeds
34/// * `Err` if the format is not recognized
35impl TryFrom<String> for SshUrl {
36    type Error = Box<dyn Error>;
37
38    fn try_from(value: String) -> Result<Self, Self::Error> {
39        // https://github.com/ReeseHatfield/oseda-lib-testing/
40        // into
41        // git@github.com:ReeseHatfield/oseda-lib-testing.git
42        let suffix = value
43            .strip_prefix("https://github.com/")
44            .ok_or("Could not get SSH URL")?;
45
46        Ok(SshUrl(format!(
47            "git@github.com:{}.git",
48            suffix.trim_end_matches('/')
49        )))
50    }
51}
52
53/// Deploys an Oseda project to the provided fork URL
54///
55/// # Arguments
56/// * `opts` - options with the `fork_url` for the deployment target
57///
58/// # Returns
59/// * `Ok(())` on success
60/// * `Err` if any git, file, or config step fails, including a check failure
61pub fn deploy(opts: DeployOptions) -> Result<(), Box<dyn Error>> {
62    let tmp_dir = tempfile::tempdir()?;
63    let repo_path = tmp_dir.path();
64
65    let ssh_url: SshUrl = opts.fork_url.try_into()?;
66
67    git(
68        repo_path,
69        &["clone", "--no-checkout", ssh_url.0.as_str(), "."],
70    )?;
71
72    println!("Running git with sparse checkout");
73    git(repo_path, &["sparse-checkout", "init", "--cone"])?;
74    git(repo_path, &["sparse-checkout", "set", "courses"])?;
75    git(repo_path, &["checkout"])?;
76
77    let course_name = get_current_dir_name()?;
78    let new_course_dir = repo_path.join("courses").join(&course_name);
79
80    copy_dir_all(env::current_dir()?, &new_course_dir)?;
81
82    // bails if config is bad
83    //
84    // force a no-skip-git
85    let conf = config::read_and_validate_config()?;
86
87    println!("Committing files to remote...");
88    git(repo_path, &["add", "."])?;
89    git(
90        repo_path,
91        &["commit", "-m", &format!("Add course: {}", conf.title)],
92    )?;
93    git(repo_path, &["push"])?;
94
95    config::update_time(conf)?;
96
97    println!("Project successfully pushed to remote.");
98
99    // https://github.com/oseda-dev/oseda-lib/compare/main...ReeseHatfield:oseda-lib:main?expand=1
100
101    match github::get_config_from_user_git("user.name") {
102        Some(github_username) => {
103            let pull_request_url = format!(
104                "https://github.com/oseda-dev/oseda-lib/compare/main...{}:oseda-lib:main?expand=1",
105                github_username
106            );
107
108            println!("Add your presentation to oseda.net by making a Pull Request at:");
109            println!();
110            println!("{}", pull_request_url);
111
112            if open::that(pull_request_url.clone()).is_err() {
113                return Err(format!("Please visit {pull_request_url} in a browser and submit a pull-request by hand").into());
114            };
115        }
116        None => {
117            println!("Error: could not get github username");
118            return Err("Deployment failed due to missing github credential. Pleas ensure user.name matches your github username".into());
119        }
120    }
121
122    Ok(())
123}
124
125/// Util fn to get the current working directory name
126///
127/// # Returns
128/// * `Ok(String)` with the directory name
129/// * `Err` if the name failed to be extracted
130fn get_current_dir_name() -> Result<String, Box<dyn Error>> {
131    // this is like really stupid to have this, since
132    // this logic is basically already used in `check`
133    // but really most of that logic should be moved to a config.rs file
134    // but until then, I am just reading the cwd with this
135    let cwd = env::current_dir()?;
136    let name = cwd
137        .file_name()
138        .ok_or("couldn't get directory name")?
139        .to_string_lossy()
140        .to_string();
141    Ok(name)
142}
143
144/// Recursively copy a directory
145/// https://stackoverflow.com/questions/26958489/how-to-copy-a-folder-recursively-in-rust
146fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<(), Box<dyn Error>> {
147    let src = src.as_ref();
148    let dst = dst.as_ref();
149
150    fs::create_dir_all(dst)?;
151
152    for entry in fs::read_dir(src)? {
153        let entry = entry?;
154        let entry_path = entry.path();
155
156        // skip `.git` directory
157        if entry_path.ends_with(".git") {
158            continue;
159        }
160
161        let ty = entry.file_type()?;
162
163        if ty.is_dir() {
164            copy_dir_all(&entry_path, dst.join(entry.file_name()))?;
165        } else {
166            fs::copy(&entry_path, dst.join(entry.file_name()))?;
167        }
168    }
169
170    Ok(())
171}