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    config::update_time(conf)?;
88    println!("Committing files to remote...");
89    git(repo_path, &["add", "."])?;
90    git(repo_path, &["commit", "-m", "Add new course"])?;
91    git(repo_path, &["push"])?;
92
93    println!("Project successfully pushed to remote.");
94
95    // https://github.com/oseda-dev/oseda-lib/compare/main...ReeseHatfield:oseda-lib:main?expand=1
96
97    match github::get_config_from_user_git("user.name") {
98        Some(github_username) => {
99            let pull_request_url = format!(
100                "https://github.com/oseda-dev/oseda-lib/compare/main...{}:oseda-lib:main?expand=1",
101                github_username
102            );
103
104            println!("Add your presentation to oseda.net by making a Pull Request at:");
105            println!();
106            println!("{}", pull_request_url);
107
108            if open::that(pull_request_url.clone()).is_err() {
109                return Err(format!("Please visit {pull_request_url} in a browser and submit a pull-request by hand").into());
110            };
111
112        }
113        None => {
114            println!("Error: could not get github username");
115            return Err("Deployment failed due to missing github credential. Pleas ensure user.name matches your github username".into());
116        }
117    }
118
119    Ok(())
120}
121
122/// Util fn to get the current working directory name
123///
124/// # Returns
125/// * `Ok(String)` with the directory name
126/// * `Err` if the name failed to be extracted
127fn get_current_dir_name() -> Result<String, Box<dyn Error>> {
128    // this is like really stupid to have this, since
129    // this logic is basically already used in `check`
130    // but really most of that logic should be moved to a config.rs file
131    // but until then, I am just reading the cwd with this
132    let cwd = env::current_dir()?;
133    let name = cwd
134        .file_name()
135        .ok_or("couldn't get directory name")?
136        .to_string_lossy()
137        .to_string();
138    Ok(name)
139}
140
141/// Recursively copy a directory
142/// https://stackoverflow.com/questions/26958489/how-to-copy-a-folder-recursively-in-rust
143fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<(), Box<dyn Error>> {
144    let src = src.as_ref();
145    let dst = dst.as_ref();
146
147    fs::create_dir_all(dst)?;
148
149    for entry in fs::read_dir(src)? {
150        let entry = entry?;
151        let entry_path = entry.path();
152
153        // skip `.git` directory
154        if entry_path.ends_with(".git") {
155            continue;
156        }
157
158        let ty = entry.file_type()?;
159
160        if ty.is_dir() {
161            copy_dir_all(&entry_path, dst.join(entry.file_name()))?;
162        } else {
163            fs::copy(&entry_path, dst.join(entry.file_name()))?;
164        }
165    }
166
167    Ok(())
168}