Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add part 2 of the tutorial: CLI Contact Manager #86

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
65de70f
Added domain for project
Hombre-x Aug 7, 2024
d752157
Added `ContactManager` algebra
Hombre-x Aug 7, 2024
1519e00
Added `Cli`
Hombre-x Aug 7, 2024
021fcec
Added `Prompt`
Hombre-x Aug 7, 2024
a2d1796
Created project entry point on `App`
Hombre-x Aug 7, 2024
6495d1e
Changed monad sequencing in `App`
Hombre-x Aug 10, 2024
fde5135
Changed domain structure
Hombre-x Aug 10, 2024
5adb157
Changed domain imports
Hombre-x Aug 10, 2024
9818555
Added input connection on `build.sbt`
Hombre-x Aug 16, 2024
1fdac05
Changed `Argumetn` to `CliCommand`
Hombre-x Aug 16, 2024
f52773e
Changed working path creation to the start of the program
Hombre-x Aug 16, 2024
605ae01
Formatting changes
Hombre-x Aug 16, 2024
33e5ff7
Created first part of the CM tutorial
Hombre-x Aug 16, 2024
3c56fca
Added `Cli` section and `Promt` to the project in the docs
Hombre-x Aug 18, 2024
ffe2342
Modified `readLines` methods
Hombre-x Aug 18, 2024
f2f711c
Formatted files before Pull Request
Hombre-x Aug 18, 2024
1f5c407
Changed non-existant methods
Hombre-x Aug 18, 2024
6cd035f
Changed length test
Hombre-x Aug 18, 2024
e514aef
Addressed missing match cases
Hombre-x Aug 18, 2024
7fc2903
Changed `showPersisted` to `encodeContact`
Hombre-x Aug 18, 2024
5e7d6a2
Merge branch 'davenverse:main' into site-project
Hombre-x Aug 23, 2024
29bfaee
Rephrased some paragraphs at CM tutorial
Hombre-x Aug 23, 2024
38d6d91
Revert "Changed length test"
Hombre-x Aug 23, 2024
7d0acf0
Revert "Changed non-existant methods"
Hombre-x Aug 23, 2024
98bbeaf
Revert "Modified `readLines` methods"
Hombre-x Aug 23, 2024
36fe704
Added suggestions to the `ContactManager`
Hombre-x Sep 8, 2024
676c442
Updated docs of the `tutorial` section
Hombre-x Sep 8, 2024
401ad95
Merge branch 'main' into site-project
Hombre-x Sep 8, 2024
243580e
Changed given to implicit declaration
Hombre-x Sep 8, 2024
d791df3
Formatting files before PR
Hombre-x Sep 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ lazy val examples = project
.dependsOn(core.jvm)
.settings(
name := "shellfish-examples",
Compile / run / fork := true
Compile / run / fork := true,
run / connectInput := true
TonioGela marked this conversation as resolved.
Show resolved Hide resolved
)

import Site.SiteConfig
Expand Down
643 changes: 643 additions & 0 deletions docs/tutorial/creating_cli.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/tutorial/directory.conf
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ laika.navigationOrder = [
path.md
reading_writing.md
file_handling.md
creating_cli.md
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2024 Typelevel
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

package io.chrisdavenport.shellfish.contacts.app

import cats.syntax.applicative.*
import cats.effect.{ExitCode, IO, IOApp}
import fs2.io.file.Path
import io.chrisdavenport.shellfish.contacts.cli.{Cli, Prompt}
import io.chrisdavenport.shellfish.contacts.core.ContactManager
import io.chrisdavenport.shellfish.contacts.domain.argument.*
import io.chrisdavenport.shellfish.syntax.path.*

object App extends IOApp {

private val createBookPath: IO[Path] = for {
home <- userHome
dir = home / ".shellfish"
path = dir / "contacts.data"
exists <- path.exists
_ <- dir.createDirectories.unlessA(exists)
_ <- path.createFile.unlessA(exists)
} yield path

def run(args: List[String]): IO[ExitCode] = createBookPath
.map(ContactManager(_))
.flatMap { implicit cm =>
Prompt.parsePrompt(args) match {
case Help => Cli.helpCommand
case AddContact => Cli.addCommand
case RemoveContact(username) => Cli.removeCommand(username)
case SearchId(username) => Cli.searchUsernameCommand(username)
case SearchName(name) => Cli.searchNameCommand(name)
case SearchEmail(email) => Cli.searchEmailCommand(email)
case SearchNumber(number) => Cli.searchNumberCommand(number)
case ViewAll => Cli.viewAllCommand
case UpdateContact(username, flags) =>
Cli.updateCommand(username, flags)
}
}
.as(ExitCode.Success)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2024 Typelevel
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

package io.chrisdavenport.shellfish.contacts.cli

import cats.effect.IO
import cats.syntax.all.*

import io.chrisdavenport.shellfish.contacts.core.ContactManager
import io.chrisdavenport.shellfish.contacts.domain.flag.*
import io.chrisdavenport.shellfish.contacts.domain.contact.*

object Cli {

def addCommand(implicit cm: ContactManager): IO[Unit] =
for {
username <- IO.println("Enter the username: ") >> IO.readLine
firstName <- IO.println("Enter the first name: ") >> IO.readLine
lastName <- IO.println("Enter the last name: ") >> IO.readLine
phoneNumber <- IO.println("Enter the phone number: ") >> IO.readLine
email <- IO.println("Enter the email: ") >> IO.readLine

contact = Contact(username, firstName, lastName, phoneNumber, email)

_ <- cm
.addContact(contact)
.flatMap(username => IO.println(s"Contact $username added"))
.handleErrorWith {
case ContactFound(username) =>
IO.println(s"Contact $username already exists")
case e =>
IO.println(s"An error occurred: \n${e.printStackTrace()}")
}
} yield ()

def removeCommand(username: Username)(implicit cm: ContactManager): IO[Unit] =
cm.removeContact(username) >> IO.println(s"Contact $username removed")

def searchUsernameCommand(
username: Username
)(implicit cm: ContactManager): IO[Unit] =
cm.searchUsername(username).flatMap {
case Some(c) => IO.println(c.show)
case None => IO.println(s"Contact $username not found")
}

def searchNameCommand(name: Name)(implicit cm: ContactManager): IO[Unit] =
for {
contacts <- cm.searchName(name)
_ <- contacts.traverse_(c => IO.println(c.show))
} yield ()

def searchEmailCommand(email: Email)(implicit cm: ContactManager): IO[Unit] =
for {
contacts <- cm.searchEmail(email)
_ <- contacts.traverse_(c => IO.println(c.show))
} yield ()

def searchNumberCommand(
number: PhoneNumber
)(implicit cm: ContactManager): IO[Unit] =
for {
contacts <- cm.searchNumber(number)
_ <- contacts.traverse_(c => IO.println(c.show))
} yield ()

def viewAllCommand(implicit cm: ContactManager): IO[Unit] = for {
contacts <- cm.getAll
_ <- contacts.traverse_(c => IO.println(c.show))
} yield ()

def updateCommand(username: Username, options: List[Flag])(implicit
cm: ContactManager
): IO[Unit] = cm
.updateContact(username) { prev =>
options.foldLeft(prev) { (acc, flag) =>
flag match {
case FirstNameFlag(name) => acc.copy(firstName = name)
case LastNameFlag(name) => acc.copy(lastName = name)
case PhoneNumberFlag(number) => acc.copy(phoneNumber = number)
case EmailFlag(email) => acc.copy(email = email)
case UnknownFlag(_) => acc
}
}
}
.flatMap(c => IO.println(s"Updated contact ${c.username}"))
.handleErrorWith {
case ContactNotFound(username) =>
IO.println(s"Contact $username not found")
case e =>
IO.println(s"An error occurred: \n${e.printStackTrace()}")
}

def helpCommand: IO[Unit] = IO.println(
s"""
|Usage: contacts [command]
|
|Commands:
| add
| remove <username>
| search id <username>
| search name <name>
| search email <email>
| search number <number>
| list
| update <username> [flags]
| help
|
|Flags (for update command):
| --first-name <name>
| --last-name <name>
| --phone-number <number>
| --email <email>
|
|""".stripMargin
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2024 Typelevel
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

package io.chrisdavenport.shellfish.contacts.cli

import io.chrisdavenport.shellfish.contacts.domain.argument.*
import io.chrisdavenport.shellfish.contacts.domain.flag.*

import scala.annotation.tailrec

object Prompt {
def parsePrompt(args: List[String]): CliCommand = args match {
case "add" :: Nil => AddContact
case "remove" :: username :: Nil => RemoveContact(username)
case "search" :: "id" :: username :: Nil => SearchId(username)
case "search" :: "name" :: name :: Nil => SearchName(name)
case "search" :: "email" :: email :: Nil => SearchEmail(email)
case "search" :: "number" :: number :: Nil => SearchNumber(number)
case "list" :: _ => ViewAll
case "update" :: username :: options =>
UpdateContact(username, parseUpdateFlags(options))
case Nil => Help
case "--help" :: _ => Help
case "help" :: _ => Help
case _ => Help
}

private def parseUpdateFlags(options: List[String]): List[Flag] = {

@tailrec
def tailParse(remaining: List[String], acc: List[Flag]): List[Flag] =
remaining match {
case Nil => acc
case "--first-name" :: firstName :: tail =>
tailParse(tail, FirstNameFlag(firstName) :: acc)
case "--last-name" :: lastName :: tail =>
tailParse(tail, LastNameFlag(lastName) :: acc)
case "--phone-number" :: phoneNumber :: tail =>
tailParse(tail, PhoneNumberFlag(phoneNumber) :: acc)
case "--email" :: email :: tail =>
tailParse(tail, EmailFlag(email) :: acc)
case flag :: _ => List(UnknownFlag(flag))
}

tailParse(options, Nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2024 Typelevel
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

package io.chrisdavenport.shellfish.contacts.core

import cats.syntax.all.*
import cats.effect.IO
import fs2.io.file.Path
import io.chrisdavenport.shellfish.syntax.path.*
import io.chrisdavenport.shellfish.contacts.domain.contact.*

trait ContactManager {
def addContact(contact: Contact): IO[Username]
def removeContact(username: Username): IO[Unit]
def searchUsername(username: Username): IO[Option[Contact]]
def searchName(name: Name): IO[List[Contact]]
def searchEmail(email: Email): IO[List[Contact]]
def searchNumber(number: PhoneNumber): IO[List[Contact]]
def getAll: IO[List[Contact]]
def updateContact(username: String)(modify: Contact => Contact): IO[Contact]
}

object ContactManager {
def apply(bookPath: Path): ContactManager = new ContactManager {

private def parseContact(contact: String): IO[Contact] =
contact.split('|') match {
case Array(id, firstName, lastName, phoneNumber, email) =>
Contact(id, firstName, lastName, phoneNumber, email).pure[IO]
case _ =>
new Exception(s"Invalid contact format: $contact")
.raiseError[IO, Contact]
}

Hombre-x marked this conversation as resolved.
Show resolved Hide resolved
private def encodeContact(contact: Contact): String =
s"${contact.username}|${contact.firstName}|${contact.lastName}|${contact.phoneNumber}|${contact.email}"

private def saveContacts(contacts: List[Contact]): IO[Unit] =
bookPath.writeLines(contacts.map(encodeContact))

override def addContact(contact: Contact): IO[Username] = for {
contacts <- getAll
_ <- IO(contacts.contains(contact)).ifM(
ContactFound(contact.username).raiseError[IO, Unit],
saveContacts(contact :: contacts)
)
} yield contact.username

override def removeContact(username: Username): IO[Unit] =
for {
contacts <- getAll
filteredContacts = contacts.filterNot(_.username === username)
_ <- saveContacts(filteredContacts)
} yield ()

override def searchUsername(username: Username): IO[Option[Contact]] =
getAll.map(contacts => contacts.find(_.username === username))

override def searchName(name: Name): IO[List[Contact]] =
getAll.map(contacts =>
contacts.filter(c => c.firstName === name || c.lastName === name)
)

override def searchEmail(email: Email): IO[List[Contact]] =
getAll.map(contacts => contacts.filter(_.email === email))

override def searchNumber(number: PhoneNumber): IO[List[Contact]] =
getAll.map(contacts => contacts.filter(_.phoneNumber === number))

override def getAll: IO[List[Contact]] = for {
lines <- bookPath.readLines
contacts <- lines.traverse(parseContact)
} yield contacts

override def updateContact(
username: Username
)(modify: Contact => Contact): IO[Contact] = for {
contacts <- getAll
oldContact <- contacts.find(_.username === username) match {
case None => ContactNotFound(username).raiseError[IO, Contact]
case Some(contact) => contact.pure[IO]
}
updatedContact = modify(oldContact)
updatedContacts = updatedContact :: contacts.filterNot(_ == oldContact)
_ <- saveContacts(updatedContacts)
} yield updatedContact
}
}
Loading