Skip to content

Commit d32f7f8

Browse files
authored
Merge pull request #86 from Hombre-x/site-project
Add part 2 of the tutorial: CLI Contact Manager
2 parents 8deb952 + 996bb30 commit d32f7f8

File tree

10 files changed

+1123
-1
lines changed

10 files changed

+1123
-1
lines changed

build.sbt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ lazy val examples = project
4545
.dependsOn(core.jvm)
4646
.settings(
4747
name := "shellfish-examples",
48-
Compile / run / fork := true
48+
Compile / run / fork := true,
49+
run / connectInput := true
4950
)
5051

5152
import Site.SiteConfig

docs/tutorial/creating_cli.md

Lines changed: 623 additions & 0 deletions
Large diffs are not rendered by default.

docs/tutorial/directory.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ laika.navigationOrder = [
33
path.md
44
reading_writing.md
55
file_handling.md
6+
creating_cli.md
67
]
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright (c) 2024 Typelevel
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
5+
* this software and associated documentation files (the "Software"), to deal in
6+
* the Software without restriction, including without limitation the rights to
7+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8+
* the Software, and to permit persons to whom the Software is furnished to do so,
9+
* subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16+
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18+
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19+
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
22+
package io.chrisdavenport.shellfish.contacts.app
23+
24+
import cats.syntax.applicative.*
25+
import cats.effect.{ExitCode, IO, IOApp}
26+
import fs2.io.file.Path
27+
import io.chrisdavenport.shellfish.contacts.cli.{Cli, Prompt}
28+
import io.chrisdavenport.shellfish.contacts.core.ContactManager
29+
import io.chrisdavenport.shellfish.contacts.domain.argument.*
30+
import io.chrisdavenport.shellfish.syntax.path.*
31+
32+
object App extends IOApp {
33+
34+
private val getOrCreateBookPath: IO[Path] = for {
35+
home <- userHome
36+
dir = home / ".shellfish"
37+
path = dir / "contacts.data"
38+
exists <- path.exists
39+
_ <- dir.createDirectories.unlessA(exists)
40+
_ <- path.createFile.unlessA(exists)
41+
} yield path
42+
43+
def run(args: List[String]): IO[ExitCode] = getOrCreateBookPath
44+
.map(ContactManager(_))
45+
.flatMap { implicit cm =>
46+
Prompt.parsePrompt(args) match {
47+
case Help => Cli.helpCommand
48+
case AddContact => Cli.addCommand
49+
case RemoveContact(username) => Cli.removeCommand(username)
50+
case SearchId(username) => Cli.searchUsernameCommand(username)
51+
case SearchName(name) => Cli.searchNameCommand(name)
52+
case SearchEmail(email) => Cli.searchEmailCommand(email)
53+
case SearchNumber(number) => Cli.searchNumberCommand(number)
54+
case ViewAll => Cli.viewAllCommand
55+
case UpdateContact(username, flags) =>
56+
Cli.updateCommand(username, flags)
57+
}
58+
}
59+
.as(ExitCode.Success)
60+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright (c) 2024 Typelevel
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
5+
* this software and associated documentation files (the "Software"), to deal in
6+
* the Software without restriction, including without limitation the rights to
7+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8+
* the Software, and to permit persons to whom the Software is furnished to do so,
9+
* subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16+
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18+
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19+
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
22+
package io.chrisdavenport.shellfish.contacts.cli
23+
24+
import cats.effect.IO
25+
import cats.syntax.all.*
26+
27+
import io.chrisdavenport.shellfish.contacts.core.ContactManager
28+
import io.chrisdavenport.shellfish.contacts.domain.flag.*
29+
import io.chrisdavenport.shellfish.contacts.domain.contact.*
30+
31+
object Cli {
32+
33+
def addCommand(implicit cm: ContactManager): IO[Unit] =
34+
for {
35+
username <- IO.println("Enter the username: ") >> IO.readLine
36+
firstName <- IO.println("Enter the first name: ") >> IO.readLine
37+
lastName <- IO.println("Enter the last name: ") >> IO.readLine
38+
phoneNumber <- IO.println("Enter the phone number: ") >> IO.readLine
39+
email <- IO.println("Enter the email: ") >> IO.readLine
40+
41+
contact = Contact(username, firstName, lastName, phoneNumber, email)
42+
43+
_ <- cm
44+
.addContact(contact)
45+
.flatMap(username => IO.println(s"Contact $username added"))
46+
.handleErrorWith {
47+
case ContactFound(username) =>
48+
IO.println(s"Contact $username already exists")
49+
case e =>
50+
IO.println(s"An error occurred: \n${e.printStackTrace()}")
51+
}
52+
} yield ()
53+
54+
def removeCommand(username: Username)(implicit cm: ContactManager): IO[Unit] =
55+
cm.removeContact(username) >> IO.println(s"Contact $username removed")
56+
57+
def searchUsernameCommand(
58+
username: Username
59+
)(implicit cm: ContactManager): IO[Unit] =
60+
cm.searchUsername(username).flatMap {
61+
case Some(c) => IO.println(c.show)
62+
case None => IO.println(s"Contact $username not found")
63+
}
64+
65+
def searchNameCommand(name: Name)(implicit cm: ContactManager): IO[Unit] =
66+
for {
67+
contacts <- cm.searchName(name)
68+
_ <- contacts.traverse_(c => IO.println(c.show))
69+
} yield ()
70+
71+
def searchEmailCommand(email: Email)(implicit cm: ContactManager): IO[Unit] =
72+
for {
73+
contacts <- cm.searchEmail(email)
74+
_ <- contacts.traverse_(c => IO.println(c.show))
75+
} yield ()
76+
77+
def searchNumberCommand(
78+
number: PhoneNumber
79+
)(implicit cm: ContactManager): IO[Unit] =
80+
for {
81+
contacts <- cm.searchNumber(number)
82+
_ <- contacts.traverse_(c => IO.println(c.show))
83+
} yield ()
84+
85+
def viewAllCommand(implicit cm: ContactManager): IO[Unit] = for {
86+
contacts <- cm.getAll
87+
_ <- contacts.traverse_(c => IO.println(c.show))
88+
} yield ()
89+
90+
def updateCommand(username: Username, options: List[Flag])(implicit
91+
cm: ContactManager
92+
): IO[Unit] = cm
93+
.updateContact(username) { prev =>
94+
options.foldLeft(prev) { (acc, flag) =>
95+
flag match {
96+
case FirstNameFlag(name) => acc.copy(firstName = name)
97+
case LastNameFlag(name) => acc.copy(lastName = name)
98+
case PhoneNumberFlag(number) => acc.copy(phoneNumber = number)
99+
case EmailFlag(email) => acc.copy(email = email)
100+
case UnknownFlag(_) => acc
101+
}
102+
}
103+
}
104+
.flatMap(c => IO.println(s"Updated contact ${c.username}"))
105+
.handleErrorWith {
106+
case ContactNotFound(username) =>
107+
IO.println(s"Contact $username not found")
108+
case e =>
109+
IO.println(s"An error occurred: \n${e.printStackTrace()}")
110+
}
111+
112+
def helpCommand: IO[Unit] = IO.println(
113+
s"""
114+
|Usage: contacts [command]
115+
|
116+
|Commands:
117+
| add
118+
| remove <username>
119+
| search id <username>
120+
| search name <name>
121+
| search email <email>
122+
| search number <number>
123+
| list
124+
| update <username> [flags]
125+
| help
126+
|
127+
|Flags (for update command):
128+
| --first-name <name>
129+
| --last-name <name>
130+
| --phone-number <number>
131+
| --email <email>
132+
|
133+
|""".stripMargin
134+
)
135+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (c) 2024 Typelevel
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
5+
* this software and associated documentation files (the "Software"), to deal in
6+
* the Software without restriction, including without limitation the rights to
7+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8+
* the Software, and to permit persons to whom the Software is furnished to do so,
9+
* subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16+
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18+
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19+
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
22+
package io.chrisdavenport.shellfish.contacts.cli
23+
24+
import io.chrisdavenport.shellfish.contacts.domain.argument.*
25+
import io.chrisdavenport.shellfish.contacts.domain.flag.*
26+
27+
import scala.annotation.tailrec
28+
29+
object Prompt {
30+
def parsePrompt(args: List[String]): CliCommand = args match {
31+
case "add" :: Nil => AddContact
32+
case "remove" :: username :: Nil => RemoveContact(username)
33+
case "search" :: "id" :: username :: Nil => SearchId(username)
34+
case "search" :: "name" :: name :: Nil => SearchName(name)
35+
case "search" :: "email" :: email :: Nil => SearchEmail(email)
36+
case "search" :: "number" :: number :: Nil => SearchNumber(number)
37+
case "list" :: _ => ViewAll
38+
case "update" :: username :: options =>
39+
UpdateContact(username, parseUpdateFlags(options))
40+
case Nil => Help
41+
case _ => Help
42+
}
43+
44+
private def parseUpdateFlags(options: List[String]): List[Flag] = {
45+
46+
@tailrec
47+
def tailParse(remaining: List[String], acc: List[Flag]): List[Flag] =
48+
remaining match {
49+
case Nil => acc
50+
case "--first-name" :: firstName :: tail =>
51+
tailParse(tail, FirstNameFlag(firstName) :: acc)
52+
case "--last-name" :: lastName :: tail =>
53+
tailParse(tail, LastNameFlag(lastName) :: acc)
54+
case "--phone-number" :: phoneNumber :: tail =>
55+
tailParse(tail, PhoneNumberFlag(phoneNumber) :: acc)
56+
case "--email" :: email :: tail =>
57+
tailParse(tail, EmailFlag(email) :: acc)
58+
case flag :: _ => List(UnknownFlag(flag))
59+
}
60+
61+
tailParse(options, Nil)
62+
}
63+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/*
2+
* Copyright (c) 2024 Typelevel
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
5+
* this software and associated documentation files (the "Software"), to deal in
6+
* the Software without restriction, including without limitation the rights to
7+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8+
* the Software, and to permit persons to whom the Software is furnished to do so,
9+
* subject to the following conditions:
10+
*
11+
* The above copyright notice and this permission notice shall be included in all
12+
* copies or substantial portions of the Software.
13+
*
14+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16+
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18+
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19+
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20+
*/
21+
22+
package io.chrisdavenport.shellfish.contacts.core
23+
24+
import cats.syntax.all.*
25+
import cats.effect.IO
26+
import fs2.io.file.Path
27+
import io.chrisdavenport.shellfish.syntax.path.*
28+
import io.chrisdavenport.shellfish.contacts.domain.contact.*
29+
30+
trait ContactManager {
31+
def addContact(contact: Contact): IO[Username]
32+
def removeContact(username: Username): IO[Unit]
33+
def searchUsername(username: Username): IO[Option[Contact]]
34+
def searchName(name: Name): IO[List[Contact]]
35+
def searchEmail(email: Email): IO[List[Contact]]
36+
def searchNumber(number: PhoneNumber): IO[List[Contact]]
37+
def getAll: IO[List[Contact]]
38+
def updateContact(username: String)(modify: Contact => Contact): IO[Contact]
39+
}
40+
41+
object ContactManager {
42+
def apply(bookPath: Path): ContactManager = new ContactManager {
43+
44+
private def parseContact(contact: String): IO[Contact] =
45+
contact.split('|') match {
46+
case Array(id, firstName, lastName, phoneNumber, email) =>
47+
Contact(id, firstName, lastName, phoneNumber, email).pure[IO]
48+
case _ =>
49+
new Exception(s"Invalid contact format: $contact")
50+
.raiseError[IO, Contact]
51+
}
52+
53+
private def encodeContact(contact: Contact): String =
54+
s"${contact.username}|${contact.firstName}|${contact.lastName}|${contact.phoneNumber}|${contact.email}"
55+
56+
private def saveContacts(contacts: List[Contact]): IO[Unit] =
57+
bookPath.writeLines(contacts.map(encodeContact))
58+
59+
override def addContact(contact: Contact): IO[Username] = for {
60+
contacts <- getAll
61+
_ <- IO(contacts.contains(contact)).ifM(
62+
ContactFound(contact.username).raiseError[IO, Unit],
63+
saveContacts(contact :: contacts)
64+
)
65+
} yield contact.username
66+
67+
override def removeContact(username: Username): IO[Unit] =
68+
for {
69+
contacts <- getAll
70+
filteredContacts = contacts.filterNot(_.username === username)
71+
_ <- saveContacts(filteredContacts)
72+
} yield ()
73+
74+
override def searchUsername(username: Username): IO[Option[Contact]] =
75+
getAll.map(contacts => contacts.find(_.username === username))
76+
77+
override def searchName(name: Name): IO[List[Contact]] =
78+
getAll.map(contacts =>
79+
contacts.filter(c => c.firstName === name || c.lastName === name)
80+
)
81+
82+
override def searchEmail(email: Email): IO[List[Contact]] =
83+
getAll.map(contacts => contacts.filter(_.email === email))
84+
85+
override def searchNumber(number: PhoneNumber): IO[List[Contact]] =
86+
getAll.map(contacts => contacts.filter(_.phoneNumber === number))
87+
88+
override def getAll: IO[List[Contact]] = for {
89+
lines <- bookPath.readLines
90+
contacts <- lines.traverse(parseContact)
91+
} yield contacts
92+
93+
override def updateContact(
94+
username: Username
95+
)(modify: Contact => Contact): IO[Contact] = for {
96+
contacts <- getAll
97+
oldContact <- contacts.find(_.username === username) match {
98+
case None => ContactNotFound(username).raiseError[IO, Contact]
99+
case Some(contact) => contact.pure[IO]
100+
}
101+
updatedContact = modify(oldContact)
102+
updatedContacts = updatedContact :: contacts.filterNot(_ == oldContact)
103+
_ <- saveContacts(updatedContacts)
104+
} yield updatedContact
105+
}
106+
}

0 commit comments

Comments
 (0)