| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | | use crate::auth::roles; |
| 9 | | use crate::db::{self, projects as pdb}; |
| 10 | | use crate::repos::RepoName; |
| 11 | | use crate::Shared; |
| 12 | | use oxy_core::repo::FileEdit; |
| 13 | |
|
| 14 | |
|
| 15 | |
|
| 16 | 29× | pub async fn check_name(app: &Shared, account: &db::accounts::Account, owner: &str, name: &str) -> Result<RepoName, String> { |
| 17 | 29× | let Some(n) = RepoName::new(owner.trim(), name.trim()) else { |
| 18 | 2× | return Err("A repository name is letters, digits, dots, dashes and underscores, up to 64.".into()); |
| 19 | | }; |
| 20 | 27× | if n.name.ends_with(crate::wiki::SUFFIX) { |
| 21 | 1× | return Err("A name cannot end in .wiki: that is the address of a repository's wiki.".into()); |
| 22 | 26× | } |
| 23 | 26× | if !roles::may_create_under(&app.db, account, &n.owner).await.map_err(|e| e.to_string())? { |
| 24 | 0× | return Err(format!("You cannot create repositories under {}.", n.owner)); |
| 25 | 26× | } |
| 26 | 26× | if db::repos::get(&app.db, &n.owner, &n.name).await.map_err(|e| e.to_string())?.is_some() || app.repos.exists(&n) { |
| 27 | 1× | return Err(format!("{n} exists.")); |
| 28 | 25× | } |
| 29 | 25× | Ok(n) |
| 30 | 29× | } |
| 31 | |
|
| 32 | |
|
| 33 | |
|
| 34 | | #[derive(Clone, Debug, PartialEq, Eq)] |
| 35 | | pub enum Board { |
| 36 | | None, |
| 37 | | New(String), |
| 38 | | Existing(i64), |
| 39 | | } |
| 40 | |
|
| 41 | | impl Board { |
| 42 | | |
| 43 | | |
| 44 | 29× | pub fn parse(choice: &str, title: &str, repo: &str) -> Result<Board, String> { |
| 45 | 29× | match choice.trim() { |
| 46 | 29× | "" | "none" => Ok(Board::None), |
| 47 | 5× | "new" => { |
| 48 | 3× | let t: String = title.trim().chars().take(100).collect(); |
| 49 | 3× | Ok(Board::New(if t.is_empty() { repo.to_string() } else { t })) |
| 50 | | } |
| 51 | 2× | n => n.parse().map(Board::Existing).map_err(|_| format!("No board {n}.")), |
| 52 | | } |
| 53 | 29× | } |
| 54 | | } |
| 55 | |
|
| 56 | |
|
| 57 | |
|
| 58 | 38× | pub async fn check_board(app: &Shared, account: &db::accounts::Account, owner: &str, board: &Board) -> Result<(), String> { |
| 59 | 38× | match board { |
| 60 | 34× | Board::None => Ok(()), |
| 61 | 2× | Board::New(_) => match crate::web::projects::rights_of(app, Some(account), owner).await.map_err(|e| e.to_string())? { |
| 62 | 2× | Some(r) if r.edit => Ok(()), |
| 63 | 0× | _ => Err(format!("You cannot make boards under {owner}.")), |
| 64 | | }, |
| 65 | 2× | Board::Existing(n) => { |
| 66 | 2× | let p = pdb::get(&app.db, owner, *n).await.map_err(|e| e.to_string())?.ok_or(format!("{owner} has no board {n}."))?; |
| 67 | 1× | if p.closed { |
| 68 | 0× | return Err(format!("Board {n} is closed.")); |
| 69 | 1× | } |
| 70 | 1× | match crate::web::projects::rights_of(app, Some(account), owner).await.map_err(|e| e.to_string())? { |
| 71 | 1× | Some(r) if r.edit => Ok(()), |
| 72 | 0× | _ => Err(format!("You cannot change {owner}'s boards.")), |
| 73 | | } |
| 74 | | } |
| 75 | | } |
| 76 | 38× | } |
| 77 | |
|
| 78 | |
|
| 79 | 27× | pub async fn link_board(app: &Shared, row: &db::repos::RepoRow, board: &Board, public: bool) -> sqlx::Result<Option<i64>> { |
| 80 | 27× | let p = match board { |
| 81 | 24× | Board::None => return Ok(None), |
| 82 | 2× | Board::New(title) => pdb::create(&app.db, &row.owner, title, &format!("Work on {}/{}", row.owner, row.name), public).await?, |
| 83 | 1× | Board::Existing(n) => match pdb::get(&app.db, &row.owner, *n).await? { |
| 84 | 1× | Some(p) => p, |
| 85 | 0× | None => return Ok(None), |
| 86 | | }, |
| 87 | | }; |
| 88 | 3× | pdb::link_repo(&app.db, p.id, row.id).await?; |
| 89 | 3× | Ok(Some(p.number)) |
| 90 | 27× | } |
| 91 | |
|
| 92 | |
|
| 93 | | pub const LICENCES: &[(&str, &str)] = &[ |
| 94 | | ("MIT", "use, change and share it freely, keeping the notice"), |
| 95 | | ("BSD-2-Clause", "as MIT, in the BSD's words"), |
| 96 | | ("ISC", "as MIT, in fewer words"), |
| 97 | | ]; |
| 98 | |
|
| 99 | |
|
| 100 | | pub const IGNORES: &[&str] = &["Rust", "Node", "Python", "Go", "Java and Kotlin"]; |
| 101 | |
|
| 102 | |
|
| 103 | | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 104 | | pub struct Start { |
| 105 | | pub readme: bool, |
| 106 | | |
| 107 | | pub licence: String, |
| 108 | | |
| 109 | | pub ignore: String, |
| 110 | | } |
| 111 | |
|
| 112 | | impl Start { |
| 113 | 1× | pub fn is_empty(&self) -> bool { |
| 114 | 1× | !self.readme && self.licence.is_empty() && self.ignore.is_empty() |
| 115 | 1× | } |
| 116 | |
|
| 117 | | |
| 118 | | |
| 119 | 39× | pub fn files(&self, name: &RepoName, description: &str, holder: &str, year: i32) -> Result<Vec<(String, String)>, String> { |
| 120 | 39× | let mut out = Vec::new(); |
| 121 | 39× | if self.readme { |
| 122 | 3× | let about = if description.trim().is_empty() { String::new() } else { format!("{}\n\n", description.trim()) }; |
| 123 | 3× | out.push(("README.md".to_string(), format!("# {}\n\n{about}", name.name))); |
| 124 | 36× | } |
| 125 | 39× | if !self.licence.is_empty() { |
| 126 | 2× | let text = licence(&self.licence, holder, year).ok_or(format!("No licence {}.", self.licence))?; |
| 127 | 1× | out.push(("LICENSE".to_string(), text)); |
| 128 | 37× | } |
| 129 | 38× | if !self.ignore.is_empty() { |
| 130 | 1× | let text = ignore(&self.ignore).ok_or(format!("No .oxyignore for {}.", self.ignore))?; |
| 131 | 1× | out.push((".oxyignore".to_string(), text.to_string())); |
| 132 | 37× | } |
| 133 | 38× | Ok(out) |
| 134 | 39× | } |
| 135 | | } |
| 136 | |
|
| 137 | 5× | fn licence(id: &str, holder: &str, year: i32) -> Option<String> { |
| 138 | 5× | Some(match id { |
| 139 | 5× | "MIT" => format!( |
| 140 | | "MIT License\n\nCopyright (c) {year} {holder}\n\n\ |
| 141 | | Permission is hereby granted, free of charge, to any person obtaining a copy\n\ |
| 142 | | of this software and associated documentation files (the \"Software\"), to deal\n\ |
| 143 | | in the Software without restriction, including without limitation the rights\n\ |
| 144 | | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n\ |
| 145 | | copies of the Software, and to permit persons to whom the Software is\n\ |
| 146 | | furnished to do so, subject to the following conditions:\n\n\ |
| 147 | | The above copyright notice and this permission notice shall be included in all\n\ |
| 148 | | copies or substantial portions of the Software.\n\n\ |
| 149 | | THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n\ |
| 150 | | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n\ |
| 151 | | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n\ |
| 152 | | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n\ |
| 153 | | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n\ |
| 154 | | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n\ |
| 155 | | SOFTWARE.\n" |
| 156 | | ), |
| 157 | 3× | "BSD-2-Clause" => format!( |
| 158 | | "BSD 2-Clause License\n\nCopyright (c) {year}, {holder}\n\n\ |
| 159 | | Redistribution and use in source and binary forms, with or without\n\ |
| 160 | | modification, are permitted provided that the following conditions are met:\n\n\ |
| 161 | | 1. Redistributions of source code must retain the above copyright notice, this\n \ |
| 162 | | list of conditions and the following disclaimer.\n\n\ |
| 163 | | 2. Redistributions in binary form must reproduce the above copyright notice,\n \ |
| 164 | | this list of conditions and the following disclaimer in the documentation\n \ |
| 165 | | and/or other materials provided with the distribution.\n\n\ |
| 166 | | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n\ |
| 167 | | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n\ |
| 168 | | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n\ |
| 169 | | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\n\ |
| 170 | | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\n\ |
| 171 | | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\n\ |
| 172 | | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\n\ |
| 173 | | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\n\ |
| 174 | | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n\ |
| 175 | | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" |
| 176 | | ), |
| 177 | 2× | "ISC" => format!( |
| 178 | | "ISC License\n\nCopyright (c) {year} {holder}\n\n\ |
| 179 | | Permission to use, copy, modify, and/or distribute this software for any\n\ |
| 180 | | purpose with or without fee is hereby granted, provided that the above\n\ |
| 181 | | copyright notice and this permission notice appear in all copies.\n\n\ |
| 182 | | THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\n\ |
| 183 | | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\n\ |
| 184 | | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\n\ |
| 185 | | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\n\ |
| 186 | | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\n\ |
| 187 | | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\n\ |
| 188 | | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n" |
| 189 | | ), |
| 190 | 1× | _ => return None, |
| 191 | | }) |
| 192 | 5× | } |
| 193 | |
|
| 194 | 6× | fn ignore(lang: &str) -> Option<&'static str> { |
| 195 | 6× | Some(match lang { |
| 196 | 6× | "Rust" => "# Build output\ntarget/\n", |
| 197 | 4× | "Node" => "# Dependencies and build output\nnode_modules/\ndist/\n*.log\n", |
| 198 | 3× | "Python" => "# Caches, environments and build output\n__pycache__/\n*.pyc\n.venv/\nbuild/\ndist/\n*.egg-info/\n", |
| 199 | 2× | "Go" => "# Build output\nbin/\n*.test\n", |
| 200 | 1× | "Java and Kotlin" => "# Build output and tool state\nbuild/\n.gradle/\nout/\n*.class\n", |
| 201 | 0× | _ => return None, |
| 202 | | }) |
| 203 | 6× | } |
| 204 | |
|
| 205 | |
|
| 206 | |
|
| 207 | |
|
| 208 | 27× | pub async fn commit_start( |
| 209 | 27× | app: &Shared, |
| 210 | 27× | row: &db::repos::RepoRow, |
| 211 | 27× | by: &db::accounts::Account, |
| 212 | 27× | files: Vec<(String, String)>, |
| 213 | 27× | ) -> Result<(), String> { |
| 214 | 27× | if files.is_empty() { |
| 215 | 27× | return Ok(()); |
| 216 | 0× | } |
| 217 | 0× | let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
| 218 | 0× | let signer = crate::web::edit::signer_for(app, by.id).await?; |
| 219 | 0× | let paths: Vec<String> = files.iter().map(|(p, _)| p.clone()).collect(); |
| 220 | 0× | let edits: Vec<FileEdit> = files.into_iter().map(|(path, content)| FileEdit { path, content: Some(content.into_bytes()) }).collect(); |
| 221 | 0× | let b = row.default_bookmark.clone(); |
| 222 | 0× | crate::repos::edits::commit(&app.repos, &name, b.clone(), b, edits, format!("Start with {}", paths.join(", ")), signer) |
| 223 | 0× | .await |
| 224 | 0× | .map_err(|e| e.to_string())?; |
| 225 | 0× | db::accounts::audit(&app.db, Some(by), "repo.started", &name.to_string(), &paths.join(", ")).await.ok(); |
| 226 | 0× | Ok(()) |
| 227 | 27× | } |
| 228 | |
|
| 229 | | #[cfg(test)] |
| 230 | | mod tests { |
| 231 | | use super::*; |
| 232 | |
|
| 233 | | #[test] |
| 234 | 1× | fn boards_and_starting_files() { |
| 235 | 1× | assert_eq!(Board::parse("", "", "demo").unwrap(), Board::None); |
| 236 | 1× | assert_eq!(Board::parse("new", " ", "demo").unwrap(), Board::New("demo".into())); |
| 237 | 1× | assert_eq!(Board::parse("new", "Roadmap", "demo").unwrap(), Board::New("Roadmap".into())); |
| 238 | 1× | assert_eq!(Board::parse("4", "", "demo").unwrap(), Board::Existing(4)); |
| 239 | 1× | assert!(Board::parse("four", "", "demo").is_err()); |
| 240 | 1× | let name = RepoName::new("andy", "demo").unwrap(); |
| 241 | 1× | let s = Start { readme: true, licence: "MIT".into(), ignore: "Rust".into() }; |
| 242 | 1× | let files = s.files(&name, "A demo.", "Andy McCall", 2026).unwrap(); |
| 243 | 3× | let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect(); |
| 244 | 1× | assert_eq!(paths, ["README.md", "LICENSE", ".oxyignore"]); |
| 245 | 1× | assert_eq!(files[0].1, "# demo\n\nA demo.\n\n"); |
| 246 | 1× | assert!(files[1].1.starts_with("MIT License\n\nCopyright (c) 2026 Andy McCall\n")); |
| 247 | 1× | assert_eq!(files[2].1, "# Build output\ntarget/\n"); |
| 248 | 3× | for (id, _) in LICENCES { |
| 249 | 3× | assert!(licence(id, "X", 2026).unwrap().contains("2026"), "{id}"); |
| 250 | | } |
| 251 | 5× | for lang in IGNORES { |
| 252 | 5× | assert!(ignore(lang).is_some(), "{lang}"); |
| 253 | | } |
| 254 | 1× | assert!(Start { licence: "GPL".into(), ..Default::default() }.files(&name, "", "X", 2026).is_err()); |
| 255 | 1× | assert!(Start::default().is_empty()); |
| 256 | 1× | } |
| 257 | | } |